Skip to content

Commit f99fd1c

Browse files
committed
feat: prompt user when attempting to format a read-only file
1 parent f391665 commit f99fd1c

7 files changed

Lines changed: 214 additions & 3 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Vegard IT GmbH and others.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Sebastian Thomschke (Vegard IT GmbH) - initial implementation.
11+
*******************************************************************************/
12+
package org.eclipse.lsp4e.test.format;
13+
14+
import static org.junit.Assert.assertTrue;
15+
16+
import java.util.Arrays;
17+
import java.util.HashSet;
18+
import java.util.List;
19+
import java.util.concurrent.atomic.AtomicBoolean;
20+
21+
import org.eclipse.core.resources.IFile;
22+
import org.eclipse.core.resources.ResourceAttributes;
23+
import org.eclipse.jface.text.IDocument;
24+
import org.eclipse.jface.text.TextSelection;
25+
import org.eclipse.lsp4e.test.utils.AbstractTestWithProject;
26+
import org.eclipse.lsp4e.test.utils.TestUtils;
27+
import org.eclipse.lsp4e.tests.mock.MockLanguageServer;
28+
import org.eclipse.lsp4e.ui.Messages;
29+
import org.eclipse.lsp4j.Position;
30+
import org.eclipse.lsp4j.Range;
31+
import org.eclipse.lsp4j.TextEdit;
32+
import org.eclipse.swt.SWT;
33+
import org.eclipse.swt.widgets.Button;
34+
import org.eclipse.swt.widgets.Composite;
35+
import org.eclipse.swt.widgets.Control;
36+
import org.eclipse.swt.widgets.Display;
37+
import org.eclipse.swt.widgets.Event;
38+
import org.eclipse.swt.widgets.Shell;
39+
import org.eclipse.ui.PlatformUI;
40+
import org.eclipse.ui.handlers.IHandlerService;
41+
import org.eclipse.ui.texteditor.ITextEditor;
42+
import org.junit.Test;
43+
44+
public class FormatHandlerReadOnlyTest extends AbstractTestWithProject {
45+
46+
@Test
47+
public void testFormatOnReadOnlyFileAndMakeWritable() throws Exception {
48+
// Mock formatting to prepend "//" at the start of each line
49+
var edits = List.of( //
50+
new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "//"),
51+
new TextEdit(new Range(new Position(1, 0), new Position(1, 0)), "//"));
52+
MockLanguageServer.INSTANCE.setFormattingTextEdits(edits);
53+
54+
String content = "line1\nline2\n";
55+
IFile file = TestUtils.createUniqueTestFile(project, content);
56+
57+
// Make file read-only
58+
ResourceAttributes attrs = file.getResourceAttributes();
59+
attrs.setReadOnly(true);
60+
file.setResourceAttributes(attrs);
61+
assertTrue(file.getResourceAttributes().isReadOnly());
62+
63+
// Open editor and select the whole document
64+
var editor = TestUtils.openEditor(file);
65+
var textEditor = (ITextEditor) editor;
66+
IDocument doc = textEditor.getDocumentProvider().getDocument(textEditor.getEditorInput());
67+
textEditor.getSelectionProvider().setSelection(new TextSelection(0, doc.getLength()));
68+
69+
// Before executing the format command, set up a poller that clicks "Yes"
70+
// and records that the dialog was shown
71+
var display = Display.getDefault();
72+
var beforeShells = new HashSet<>(Arrays.asList(display.getShells()));
73+
final var dialogShown = new AtomicBoolean(false);
74+
display.asyncExec(new Runnable() {
75+
Button findYesButton(Composite parent) {
76+
for (Control child : parent.getChildren()) {
77+
if (child instanceof Button button && button.getText().toLowerCase().contains("yes")) {
78+
return button;
79+
}
80+
if (child instanceof Composite composite) {
81+
Button result = findYesButton(composite);
82+
if (result != null) {
83+
return result;
84+
}
85+
}
86+
}
87+
return null;
88+
}
89+
90+
@Override
91+
public void run() {
92+
Shell newShell = TestUtils.findNewShell(beforeShells, display);
93+
if (newShell == null || !Messages.LSPFormatHandler_ReadOnlyEditor_title.equals(newShell.getText())) {
94+
display.timerExec(50, this);
95+
return;
96+
}
97+
dialogShown.set(true);
98+
Button yes = findYesButton(newShell);
99+
if (yes != null) {
100+
yes.notifyListeners(SWT.Selection, new Event());
101+
}
102+
}
103+
});
104+
105+
// Run format command which should prompt and then make the file writable
106+
IHandlerService handlerService = PlatformUI.getWorkbench().getService(IHandlerService.class);
107+
handlerService.executeCommand("org.eclipse.lsp4e.format", null);
108+
109+
// File was made writable and edits were applied
110+
TestUtils.waitForAndAssertCondition(5_000, dialogShown::get);
111+
TestUtils.waitForAndAssertCondition(5_000, () -> !file.getResourceAttributes().isReadOnly());
112+
TestUtils.waitForAndAssertCondition(5_000, () -> "//line1\n//line2\n".equals(doc.get()));
113+
}
114+
}

org.eclipse.lsp4e/src/org/eclipse/lsp4e/DocumentContentSynchronizer.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,10 @@ public void documentAboutToBeSaved() {
259259
List<TextEdit> edits = languageServerWrapper.executeImpl(ls -> ls.getTextDocumentService().willSaveWaitUntil(params))
260260
.get(lsToWillSaveWaitUntilTimeout(), TimeUnit.SECONDS);
261261
try {
262-
LSPEclipseUtils.applyEdits(document, edits);
262+
// Avoid applying edits to read-only documents
263+
if (!LSPEclipseUtils.isReadOnly(document)) {
264+
LSPEclipseUtils.applyEdits(document, edits);
265+
}
263266
} catch (BadLocationException e) {
264267
LanguageServerPlugin.logError(e);
265268
}
@@ -287,7 +290,10 @@ private void formatDocument() {
287290
var edits = requestFormatting(document, textSelection).get(lsToWillSaveWaitUntilTimeout(), TimeUnit.SECONDS);
288291
if (edits != null) {
289292
try {
290-
edits.apply();
293+
// Avoid applying edits to read-only documents
294+
if (!LSPEclipseUtils.isReadOnly(document)) {
295+
edits.apply();
296+
}
291297
} catch (final ConcurrentModificationException ex) {
292298
ServerMessageHandler.showMessage(Messages.LSPFormatHandler_DiscardedFormat, new MessageParams(MessageType.Error, Messages.LSPFormatHandler_DiscardedFormatResponse));
293299
}

org.eclipse.lsp4e/src/org/eclipse/lsp4e/LSPEclipseUtils.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
import org.eclipse.core.runtime.CoreException;
7575
import org.eclipse.core.runtime.IPath;
7676
import org.eclipse.core.runtime.IProgressMonitor;
77+
import org.eclipse.core.runtime.IStatus;
7778
import org.eclipse.core.runtime.NullProgressMonitor;
7879
import org.eclipse.core.runtime.OperationCanceledException;
7980
import org.eclipse.core.runtime.Path;
@@ -1625,4 +1626,17 @@ public static boolean isReadOnly(final IResource resource) {
16251626
ResourceAttributes attributes = resource.getResourceAttributes();
16261627
return attributes != null && attributes.isReadOnly();
16271628
}
1629+
1630+
public static IStatus setWritable(final IFile file) {
1631+
ResourceAttributes attrs = file.getResourceAttributes();
1632+
if (attrs != null) {
1633+
try {
1634+
attrs.setReadOnly(false);
1635+
file.setResourceAttributes(attrs);
1636+
} catch (CoreException e) {
1637+
return e.getStatus();
1638+
}
1639+
}
1640+
return Status.OK_STATUS;
1641+
}
16281642
}

org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/format/LSPFormatHandler.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
import java.util.ConcurrentModificationException;
1717

1818
import org.eclipse.core.commands.ExecutionEvent;
19+
import org.eclipse.core.resources.IFile;
20+
import org.eclipse.core.runtime.Adapters;
21+
import org.eclipse.core.runtime.IStatus;
1922
import org.eclipse.jdt.annotation.Nullable;
23+
import org.eclipse.jface.dialogs.MessageDialog;
2024
import org.eclipse.jface.text.BadLocationException;
2125
import org.eclipse.jface.text.IDocument;
2226
import org.eclipse.jface.text.ITextSelection;
@@ -29,15 +33,47 @@
2933
import org.eclipse.lsp4e.ui.UI;
3034
import org.eclipse.lsp4j.MessageParams;
3135
import org.eclipse.lsp4j.MessageType;
36+
import org.eclipse.osgi.util.NLS;
37+
import org.eclipse.ui.IEditorInput;
3238
import org.eclipse.ui.handlers.HandlerUtil;
39+
import org.eclipse.ui.part.FileEditorInput;
3340
import org.eclipse.ui.texteditor.ITextEditor;
41+
import org.eclipse.ui.texteditor.ITextEditorExtension;
3442

3543
public class LSPFormatHandler extends LSPDocumentAbstractHandler {
3644

3745
private final LSPFormatter formatter = new LSPFormatter();
3846

3947
@Override
4048
protected void execute(ExecutionEvent event, ITextEditor textEditor) {
49+
// If the editor input is read-only, prompt to make writable
50+
final var editorExt = Adapters.adapt(textEditor, ITextEditorExtension.class);
51+
if (editorExt != null && editorExt.isEditorInputReadOnly()) {
52+
final IEditorInput input = textEditor.getEditorInput();
53+
final IFile file = input instanceof FileEditorInput fileInput ? fileInput.getFile() : null;
54+
if (file == null) {
55+
MessageDialog.openInformation(HandlerUtil.getActiveShell(event),
56+
Messages.LSPFormatHandler_ReadOnlyEditor_title,
57+
NLS.bind(Messages.LSPFormatHandler_ReadOnlyEditor_inputReadonly, input.getToolTipText()));
58+
return;
59+
}
60+
61+
if (!MessageDialog.openQuestion(HandlerUtil.getActiveShell(event),
62+
Messages.LSPFormatHandler_ReadOnlyEditor_title,
63+
NLS.bind(Messages.LSPFormatHandler_ReadOnlyEditor_fileReadonly, file.getLocation().toFile()))) {
64+
return; // user aborted
65+
}
66+
67+
final IStatus status = LSPEclipseUtils.setWritable(file);
68+
if (!status.isOK()) {
69+
MessageDialog.openError(HandlerUtil.getActiveShell(event),
70+
Messages.LSPFormatHandler_ReadOnlyEditor_title,
71+
NLS.bind(Messages.LSPFormatHandler_ReadOnlyEditor_makingWritableFailed,
72+
file.getLocation().toFile(), status.getMessage()));
73+
return;
74+
}
75+
}
76+
4177
final ISelection selection = HandlerUtil.getCurrentSelection(event);
4278
if (selection instanceof final ITextSelection textSelection && !textSelection.isEmpty()) {
4379

org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/format/LSPonTypeFormattingReconcilingStrategy.java

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
2020
import org.eclipse.core.commands.operations.IOperationHistory;
2121
import org.eclipse.core.commands.operations.IOperationHistoryListener;
2222
import org.eclipse.core.commands.operations.OperationHistoryEvent;
23+
import org.eclipse.core.resources.IFile;
2324
import org.eclipse.core.runtime.IProgressMonitor;
2425
import org.eclipse.core.runtime.IStatus;
2526
import org.eclipse.core.runtime.Status;
2627
import org.eclipse.jdt.annotation.Nullable;
28+
import org.eclipse.jface.dialogs.MessageDialog;
2729
import org.eclipse.jface.preference.IPreferenceStore;
2830
import org.eclipse.jface.text.BadLocationException;
2931
import org.eclipse.jface.text.DocumentEvent;
@@ -43,10 +45,12 @@
4345
import org.eclipse.lsp4e.VersionedEdits;
4446
import org.eclipse.lsp4e.internal.DocumentUtil;
4547
import org.eclipse.lsp4e.ui.FormatterPreferencePage;
48+
import org.eclipse.lsp4e.ui.Messages;
4649
import org.eclipse.lsp4j.DocumentOnTypeFormattingParams;
4750
import org.eclipse.lsp4j.FormattingOptions;
4851
import org.eclipse.lsp4j.TextDocumentIdentifier;
4952
import org.eclipse.lsp4j.jsonrpc.messages.Either;
53+
import org.eclipse.osgi.util.NLS;
5054
import org.eclipse.swt.custom.StyledText;
5155
import org.eclipse.ui.PlatformUI;
5256
import org.eclipse.ui.progress.UIJob;
@@ -57,7 +61,7 @@ public class LSPonTypeFormattingReconcilingStrategy implements IReconcilingStrat
5761
private final IPropertyChangeListener formattingPrefsListener = (final PropertyChangeEvent event) -> {
5862
final var newValue = event.getNewValue();
5963
if (newValue != null) {
60-
if (event.getProperty() == FormatterPreferencePage.PREF_ON_TYPE_FORMATTING_ENABLED) {
64+
if (FormatterPreferencePage.PREF_ON_TYPE_FORMATTING_ENABLED.equals(event.getProperty())) {
6165
isOnTypeFormattingEnabled = Boolean.parseBoolean(newValue.toString());
6266
}
6367
}
@@ -158,6 +162,35 @@ void doFormatOnType(@Nullable ITextViewer viewer, @Nullable IDocument document,
158162
if (document == null || event == null || event.fText.isEmpty() || viewer == null) {
159163
return;
160164
}
165+
166+
// If the backing resource is read-only, prompt to make it writable
167+
if (LSPEclipseUtils.isReadOnly(document)) {
168+
final var shell = viewer.getTextWidget() != null ? viewer.getTextWidget().getShell() : null;
169+
IFile file = LSPEclipseUtils.getFile(document);
170+
if (file == null) {
171+
if (shell != null) {
172+
final var label = LSPEclipseUtils.toUri(document);
173+
MessageDialog.openInformation(shell, Messages.LSPFormatHandler_ReadOnlyEditor_title,
174+
NLS.bind(Messages.LSPFormatHandler_ReadOnlyEditor_inputReadonly, String.valueOf(label)));
175+
}
176+
return;
177+
}
178+
179+
if (shell != null && !MessageDialog.openQuestion(shell, Messages.LSPFormatHandler_ReadOnlyEditor_title,
180+
NLS.bind(Messages.LSPFormatHandler_ReadOnlyEditor_fileReadonly, file.getLocation().toFile()))) {
181+
return; // user aborted
182+
}
183+
184+
final IStatus status = LSPEclipseUtils.setWritable(file);
185+
if (!status.isOK()) {
186+
if (shell != null) {
187+
MessageDialog.openError(shell, Messages.LSPFormatHandler_ReadOnlyEditor_title,
188+
NLS.bind(Messages.LSPFormatHandler_ReadOnlyEditor_makingWritableFailed,
189+
file.getLocation().toFile(), status.getMessage()));
190+
}
191+
return;
192+
}
193+
}
161194
final String[] triggerCharArr = new String[] { "" }; //$NON-NLS-1$
162195
var executor = LanguageServers.forDocument(document)
163196
.withCapability(capabilities -> {

org.eclipse.lsp4e/src/org/eclipse/lsp4e/ui/Messages.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ public final class Messages extends NLS {
6262
public static String LSPFormatFilesHandler_FormattingSelectedFiles;
6363
public static String LSPFormatHandler_DiscardedFormat;
6464
public static String LSPFormatHandler_DiscardedFormatResponse;
65+
public static String LSPFormatHandler_ReadOnlyEditor_title;
66+
public static String LSPFormatHandler_ReadOnlyEditor_inputReadonly;
67+
public static String LSPFormatHandler_ReadOnlyEditor_fileReadonly;
68+
public static String LSPFormatHandler_ReadOnlyEditor_makingWritableFailed;
6569
public static String LSPSymbolInWorkspaceDialog_DialogLabel;
6670
public static String LSPSymbolInWorkspaceDialog_DialogTitle;
6771
public static String updateCodelensMenu_job;

org.eclipse.lsp4e/src/org/eclipse/lsp4e/ui/messages.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ LSPFormatFilesHandler_FormattingFile=Formatting {0}...
5656
LSPFormatFilesHandler_FormattingSelectedFiles=Formatting selected files...
5757
LSPFormatHandler_DiscardedFormat=Discarded Format
5858
LSPFormatHandler_DiscardedFormatResponse=The format response has been discarded because the document has changed since the format request has been sent
59+
LSPFormatHandler_ReadOnlyEditor_title=Read-only File Encountered
60+
LSPFormatHandler_ReadOnlyEditor_inputReadonly=Editor input ''{0}'' is read-only.
61+
LSPFormatHandler_ReadOnlyEditor_fileReadonly=File ''{0}'' is read-only.\n\nDo you wish to make it writable?
62+
LSPFormatHandler_ReadOnlyEditor_makingWritableFailed=Could not make ''{0}'' writable:\n\n{1}
5963
LSPSymbolInWorkspaceDialog_DialogLabel=Select an item to open.
6064
LSPSymbolInWorkspaceDialog_DialogTitle=Open Symbol in Workspace
6165
updateCodelensMenu_job=Update CodeLens menu

0 commit comments

Comments
 (0)