diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/format/FormatHandlerReadOnlyTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/format/FormatHandlerReadOnlyTest.java new file mode 100644 index 000000000..ebdc370ae --- /dev/null +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/format/FormatHandlerReadOnlyTest.java @@ -0,0 +1,114 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation. + *******************************************************************************/ +package org.eclipse.lsp4e.test.format; + +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.ResourceAttributes; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.TextSelection; +import org.eclipse.lsp4e.test.utils.AbstractTestWithProject; +import org.eclipse.lsp4e.test.utils.TestUtils; +import org.eclipse.lsp4e.tests.mock.MockLanguageServer; +import org.eclipse.lsp4e.ui.Messages; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.handlers.IHandlerService; +import org.eclipse.ui.texteditor.ITextEditor; +import org.junit.Test; + +public class FormatHandlerReadOnlyTest extends AbstractTestWithProject { + + @Test + public void testFormatOnReadOnlyFileAndMakeWritable() throws Exception { + // Mock formatting to prepend "//" at the start of each line + var edits = List.of( // + new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "//"), + new TextEdit(new Range(new Position(1, 0), new Position(1, 0)), "//")); + MockLanguageServer.INSTANCE.setFormattingTextEdits(edits); + + String content = "line1\nline2\n"; + IFile file = TestUtils.createUniqueTestFile(project, content); + + // Make file read-only + ResourceAttributes attrs = file.getResourceAttributes(); + attrs.setReadOnly(true); + file.setResourceAttributes(attrs); + assertTrue(file.getResourceAttributes().isReadOnly()); + + // Open editor and select the whole document + var editor = TestUtils.openEditor(file); + var textEditor = (ITextEditor) editor; + IDocument doc = textEditor.getDocumentProvider().getDocument(textEditor.getEditorInput()); + textEditor.getSelectionProvider().setSelection(new TextSelection(0, doc.getLength())); + + // Before executing the format command, set up a poller that clicks "Yes" + // and records that the dialog was shown + var display = Display.getDefault(); + var beforeShells = new HashSet<>(Arrays.asList(display.getShells())); + final var dialogShown = new AtomicBoolean(false); + display.asyncExec(new Runnable() { + Button findYesButton(Composite parent) { + for (Control child : parent.getChildren()) { + if (child instanceof Button button && button.getText().toLowerCase().contains("yes")) { + return button; + } + if (child instanceof Composite composite) { + Button result = findYesButton(composite); + if (result != null) { + return result; + } + } + } + return null; + } + + @Override + public void run() { + Shell newShell = TestUtils.findNewShell(beforeShells, display); + if (newShell == null || !Messages.LSPFormatHandler_ReadOnlyEditor_title.equals(newShell.getText())) { + display.timerExec(50, this); + return; + } + dialogShown.set(true); + Button yes = findYesButton(newShell); + if (yes != null) { + yes.notifyListeners(SWT.Selection, new Event()); + } + } + }); + + // Run format command which should prompt and then make the file writable + IHandlerService handlerService = PlatformUI.getWorkbench().getService(IHandlerService.class); + handlerService.executeCommand("org.eclipse.lsp4e.format", null); + + // File was made writable and edits were applied + TestUtils.waitForAndAssertCondition(5_000, dialogShown::get); + TestUtils.waitForAndAssertCondition(5_000, () -> !file.getResourceAttributes().isReadOnly()); + TestUtils.waitForAndAssertCondition(5_000, () -> "//line1\n//line2\n".equals(doc.get())); + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/ResourceUtil.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/ResourceUtil.java new file mode 100644 index 000000000..c5610f413 --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/ResourceUtil.java @@ -0,0 +1,31 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation. + *******************************************************************************/ +package org.eclipse.lsp4e.internal; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.ResourceAttributes; +import org.eclipse.core.runtime.CoreException; + +public class ResourceUtil { + + public static void setWritable(final IFile file) throws CoreException { + ResourceAttributes attrs = file.getResourceAttributes(); + if (attrs != null) { + attrs.setReadOnly(false); + file.setResourceAttributes(attrs); + } + } + + private ResourceUtil() { + + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/format/LSPFormatHandler.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/format/LSPFormatHandler.java index 6833d6dd0..5eda1bc0d 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/format/LSPFormatHandler.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/format/LSPFormatHandler.java @@ -16,7 +16,11 @@ import java.util.ConcurrentModificationException; import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.Adapters; +import org.eclipse.core.runtime.CoreException; import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jface.dialogs.MessageDialog; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.ITextSelection; @@ -25,19 +29,59 @@ import org.eclipse.lsp4e.LanguageServerPlugin; import org.eclipse.lsp4e.ServerMessageHandler; import org.eclipse.lsp4e.internal.LSPDocumentAbstractHandler; +import org.eclipse.lsp4e.internal.ResourceUtil; import org.eclipse.lsp4e.ui.Messages; import org.eclipse.lsp4e.ui.UI; import org.eclipse.lsp4j.MessageParams; import org.eclipse.lsp4j.MessageType; +import org.eclipse.osgi.util.NLS; +import org.eclipse.ui.IEditorInput; import org.eclipse.ui.handlers.HandlerUtil; +import org.eclipse.ui.part.FileEditorInput; import org.eclipse.ui.texteditor.ITextEditor; +import org.eclipse.ui.texteditor.ITextEditorExtension; public class LSPFormatHandler extends LSPDocumentAbstractHandler { private final LSPFormatter formatter = new LSPFormatter(); + static boolean setWritable(IFile file) { + final var shell = UI.getActiveShell(); + + if (!MessageDialog.openQuestion(shell, Messages.LSPFormatHandler_ReadOnlyEditor_title, + NLS.bind(Messages.LSPFormatHandler_ReadOnlyEditor_fileReadonly, file.getLocation().toFile()))) { + return false; // user aborted + } + + try { + ResourceUtil.setWritable(file); + return true; + } catch (final CoreException ex) { + MessageDialog.openError(UI.getActiveShell(), Messages.LSPFormatHandler_ReadOnlyEditor_title, + NLS.bind(Messages.LSPFormatHandler_ReadOnlyEditor_makingWritableFailed, file.getLocation().toFile(), + ex.getStatus().getMessage())); + } + return false; + } + @Override protected void execute(ExecutionEvent event, ITextEditor textEditor) { + // If the editor input is read-only, prompt to make writable + final var editorExt = Adapters.adapt(textEditor, ITextEditorExtension.class); + if (editorExt != null && editorExt.isEditorInputReadOnly()) { + final IEditorInput input = textEditor.getEditorInput(); + final IFile file = input instanceof FileEditorInput fileInput ? fileInput.getFile() : null; + if (file == null) { + MessageDialog.openInformation(HandlerUtil.getActiveShell(event), + Messages.LSPFormatHandler_ReadOnlyEditor_title, + NLS.bind(Messages.LSPFormatHandler_ReadOnlyEditor_inputReadonly, input.getToolTipText())); + return; + } + + if (!setWritable(file)) + return; + } + final ISelection selection = HandlerUtil.getCurrentSelection(event); if (selection instanceof final ITextSelection textSelection && !textSelection.isEmpty()) { diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/format/LSPonTypeFormattingReconcilingStrategy.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/format/LSPonTypeFormattingReconcilingStrategy.java index fa115e851..caacbf1ab 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/format/LSPonTypeFormattingReconcilingStrategy.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/format/LSPonTypeFormattingReconcilingStrategy.java @@ -20,10 +20,12 @@ import org.eclipse.core.commands.operations.IOperationHistory; import org.eclipse.core.commands.operations.IOperationHistoryListener; import org.eclipse.core.commands.operations.OperationHistoryEvent; +import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.Status; import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jface.dialogs.MessageDialog; import org.eclipse.jface.preference.IPreferenceStore; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.DocumentEvent; @@ -43,10 +45,13 @@ import org.eclipse.lsp4e.VersionedEdits; import org.eclipse.lsp4e.internal.DocumentUtil; import org.eclipse.lsp4e.ui.FormatterPreferencePage; +import org.eclipse.lsp4e.ui.Messages; +import org.eclipse.lsp4e.ui.UI; import org.eclipse.lsp4j.DocumentOnTypeFormattingParams; import org.eclipse.lsp4j.FormattingOptions; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.custom.StyledText; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.progress.UIJob; @@ -57,7 +62,7 @@ public class LSPonTypeFormattingReconcilingStrategy implements IReconcilingStrat private final IPropertyChangeListener formattingPrefsListener = (final PropertyChangeEvent event) -> { final var newValue = event.getNewValue(); if (newValue != null) { - if (event.getProperty() == FormatterPreferencePage.PREF_ON_TYPE_FORMATTING_ENABLED) { + if (FormatterPreferencePage.PREF_ON_TYPE_FORMATTING_ENABLED.equals(event.getProperty())) { isOnTypeFormattingEnabled = Boolean.parseBoolean(newValue.toString()); } } @@ -158,6 +163,21 @@ void doFormatOnType(@Nullable ITextViewer viewer, @Nullable IDocument document, if (document == null || event == null || event.fText.isEmpty() || viewer == null) { return; } + + // If the backing resource is read-only, prompt to make it writable + if (LSPEclipseUtils.isReadOnly(document)) { + IFile file = LSPEclipseUtils.getFile(document); + if (file == null) { + final var label = LSPEclipseUtils.toUri(document); + MessageDialog.openInformation(UI.getActiveShell(), Messages.LSPFormatHandler_ReadOnlyEditor_title, + NLS.bind(Messages.LSPFormatHandler_ReadOnlyEditor_inputReadonly, String.valueOf(label))); + return; + } + + if(!LSPFormatHandler.setWritable(file)) + return; + } + final String[] triggerCharArr = new String[] { "" }; //$NON-NLS-1$ var executor = LanguageServers.forDocument(document) .withCapability(capabilities -> { diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/ui/Messages.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/ui/Messages.java index 029f317d4..8c300685d 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/ui/Messages.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/ui/Messages.java @@ -62,6 +62,10 @@ public final class Messages extends NLS { public static String LSPFormatFilesHandler_FormattingSelectedFiles; public static String LSPFormatHandler_DiscardedFormat; public static String LSPFormatHandler_DiscardedFormatResponse; + public static String LSPFormatHandler_ReadOnlyEditor_title; + public static String LSPFormatHandler_ReadOnlyEditor_inputReadonly; + public static String LSPFormatHandler_ReadOnlyEditor_fileReadonly; + public static String LSPFormatHandler_ReadOnlyEditor_makingWritableFailed; public static String LSPSymbolInWorkspaceDialog_DialogLabel; public static String LSPSymbolInWorkspaceDialog_DialogTitle; public static String updateCodelensMenu_job; diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/ui/messages.properties b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/ui/messages.properties index 0006d2f93..96a22c16c 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/ui/messages.properties +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/ui/messages.properties @@ -56,6 +56,10 @@ LSPFormatFilesHandler_FormattingFile=Formatting {0}... LSPFormatFilesHandler_FormattingSelectedFiles=Formatting selected files... LSPFormatHandler_DiscardedFormat=Discarded Format LSPFormatHandler_DiscardedFormatResponse=The format response has been discarded because the document has changed since the format request has been sent +LSPFormatHandler_ReadOnlyEditor_title=Read-only File Encountered +LSPFormatHandler_ReadOnlyEditor_inputReadonly=Editor input ''{0}'' is read-only. +LSPFormatHandler_ReadOnlyEditor_fileReadonly=File ''{0}'' is read-only.\n\nDo you wish to make it writable? +LSPFormatHandler_ReadOnlyEditor_makingWritableFailed=Could not make ''{0}'' writable:\n\n{1} LSPSymbolInWorkspaceDialog_DialogLabel=Select an item to open. LSPSymbolInWorkspaceDialog_DialogTitle=Open Symbol in Workspace updateCodelensMenu_job=Update CodeLens menu