From b3097cfaf7de24594e616de7e962aae837419f20 Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Tue, 31 Mar 2026 16:51:35 +0100 Subject: [PATCH] implements PLN-0021 --- discussion/index.ndjson | 2 +- ...e-dec-0010-editor-write-ui-and-workflow.md | 4 +- .../java/p/studio/utilities/i18n/I18n.java | 4 + .../editor/EditorOpenFileBuffer.java | 20 ++- .../editor/EditorOpenFileSession.java | 8 + .../workspaces/editor/EditorStatusBar.java | 23 ++- .../workspaces/editor/EditorTabStrip.java | 7 +- .../workspaces/editor/EditorWorkspace.java | 157 ++++++++++++++++-- .../main/resources/i18n/messages.properties | 4 + .../resources/themes/default-prometeu.css | 51 ++++++ .../editor/EditorOpenFileSessionTest.java | 49 +++++- 11 files changed, 300 insertions(+), 29 deletions(-) diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 2faac05f..2dabbef8 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -11,4 +11,4 @@ {"type":"discussion","id":"DSC-0010","status":"done","ticket":"studio-code-editor-workspace-foundations","title":"Establish Code Editor workspace foundations in Studio without LSP","created_at":"2026-03-30","updated_at":"2026-03-31","tags":["studio","editor","workspace","multi-frontend","lsp-deferred"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0026","file":"discussion/lessons/DSC-0010-studio-code-editor-workspace-foundations/LSN-0026-read-only-editor-foundations-and-semantic-deferral.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31"}]} {"type":"discussion","id":"DSC-0011","status":"done","ticket":"compiler-analyze-compile-build-pipeline-split","title":"Split compiler pipeline into analyze, compile, and build entrypoints","created_at":"2026-03-30","updated_at":"2026-03-30","tags":["compiler","pipeline","artifacts","build","analysis"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0025","file":"discussion/lessons/DSC-0011-compiler-analyze-compile-build-pipeline-split/LSN-0025-compiler-pipeline-entrypoints-and-result-boundaries.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30"}]} {"type":"discussion","id":"DSC-0012","status":"done","ticket":"studio-editor-document-vfs-boundary","title":"Definir um boundary de VFS documental para tree/view/open files no Code Editor do Studio","created_at":"2026-03-31","updated_at":"2026-03-31","tags":["studio","editor","workspace","vfs","filesystem","boundary"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0027","file":"discussion/lessons/DSC-0012-studio-editor-document-vfs-boundary/LSN-0027-project-document-vfs-and-session-owned-editor-boundary.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31"}]} -{"type":"discussion","id":"DSC-0013","status":"open","ticket":"studio-editor-write-wave-supported-non-frontend-files","title":"Definir a wave inicial de edicao no Code Editor apenas para arquivos aceitos e nao relacionados ao FE","created_at":"2026-03-31","updated_at":"2026-03-31","tags":["studio","editor","workspace","write","read-only","vfs","frontend-boundary"],"agendas":[{"id":"AGD-0013","file":"AGD-0013-studio-editor-write-wave-supported-non-frontend-files.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31"},{"id":"AGD-0014","file":"AGD-0014-studio-editor-frontend-edit-rights.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31"}],"decisions":[{"id":"DEC-0010","file":"DEC-0010-studio-controlled-non-frontend-editor-write-wave.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0013"},{"id":"DEC-0011","file":"DEC-0011-studio-frontend-read-only-minimum-lsp-phase.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0014"}],"plans":[{"id":"PLN-0019","file":"PLN-0019-propagate-dec-0010-into-studio-and-vfs-specs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0020","file":"PLN-0020-build-dec-0010-vfs-access-policy-and-save-core.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0021","file":"PLN-0021-integrate-dec-0010-editor-write-ui-and-workflow.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0022","file":"PLN-0022-propagate-dec-0011-into-studio-vfs-and-lsp-specs.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0023","file":"PLN-0023-scaffold-flat-packed-prometeu-lsp-api-and-session-seams.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0024","file":"PLN-0024-implement-read-only-fe-diagnostics-symbols-and-definition.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0025","file":"PLN-0025-implement-fe-semantic-highlight-consumption.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0013","status":"open","ticket":"studio-editor-write-wave-supported-non-frontend-files","title":"Definir a wave inicial de edicao no Code Editor apenas para arquivos aceitos e nao relacionados ao FE","created_at":"2026-03-31","updated_at":"2026-03-31","tags":["studio","editor","workspace","write","read-only","vfs","frontend-boundary"],"agendas":[{"id":"AGD-0013","file":"AGD-0013-studio-editor-write-wave-supported-non-frontend-files.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31"},{"id":"AGD-0014","file":"AGD-0014-studio-editor-frontend-edit-rights.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31"}],"decisions":[{"id":"DEC-0010","file":"DEC-0010-studio-controlled-non-frontend-editor-write-wave.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0013"},{"id":"DEC-0011","file":"DEC-0011-studio-frontend-read-only-minimum-lsp-phase.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0014"}],"plans":[{"id":"PLN-0019","file":"PLN-0019-propagate-dec-0010-into-studio-and-vfs-specs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0020","file":"PLN-0020-build-dec-0010-vfs-access-policy-and-save-core.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0021","file":"PLN-0021-integrate-dec-0010-editor-write-ui-and-workflow.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0022","file":"PLN-0022-propagate-dec-0011-into-studio-vfs-and-lsp-specs.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0023","file":"PLN-0023-scaffold-flat-packed-prometeu-lsp-api-and-session-seams.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0024","file":"PLN-0024-implement-read-only-fe-diagnostics-symbols-and-definition.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0025","file":"PLN-0025-implement-fe-semantic-highlight-consumption.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]}],"lessons":[]} diff --git a/discussion/workflow/plans/PLN-0021-integrate-dec-0010-editor-write-ui-and-workflow.md b/discussion/workflow/plans/PLN-0021-integrate-dec-0010-editor-write-ui-and-workflow.md index 72b3f0b3..78c30fbf 100644 --- a/discussion/workflow/plans/PLN-0021-integrate-dec-0010-editor-write-ui-and-workflow.md +++ b/discussion/workflow/plans/PLN-0021-integrate-dec-0010-editor-write-ui-and-workflow.md @@ -2,9 +2,9 @@ id: PLN-0021 ticket: studio-editor-write-wave-supported-non-frontend-files title: Integrate DEC-0010 editor write UI and workflow in Studio -status: review +status: done created: 2026-03-31 -completed: +completed: 2026-03-31 tags: [studio, editor, ui, save, read-only, write-wave] --- diff --git a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index 4d468927..462dcd3a 100644 --- a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java +++ b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java @@ -87,7 +87,11 @@ public enum I18n { CODE_EDITOR_STATUS_LINE_SEPARATOR("codeEditor.status.lineSeparator"), CODE_EDITOR_STATUS_INDENTATION("codeEditor.status.indentation"), CODE_EDITOR_STATUS_LANGUAGE("codeEditor.status.language"), + CODE_EDITOR_STATUS_EDITABLE("codeEditor.status.editable"), CODE_EDITOR_STATUS_READ_ONLY("codeEditor.status.readOnly"), + CODE_EDITOR_COMMAND_SAVE("codeEditor.command.save"), + CODE_EDITOR_COMMAND_SAVE_ALL("codeEditor.command.saveAll"), + CODE_EDITOR_WARNING_FRONTEND_READ_ONLY("codeEditor.warning.frontendReadOnly"), CODE_EDITOR_UNSUPPORTED_FILE_TITLE("codeEditor.unsupportedFile.title"), CODE_EDITOR_UNSUPPORTED_FILE_MESSAGE("codeEditor.unsupportedFile.message"), diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileBuffer.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileBuffer.java index 6cc6ed1d..b4ea93b5 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileBuffer.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileBuffer.java @@ -1,5 +1,7 @@ package p.studio.workspaces.editor; +import p.studio.vfs.VfsDocumentAccessMode; + import java.nio.file.Path; import java.util.Objects; @@ -8,12 +10,28 @@ public record EditorOpenFileBuffer( String tabLabel, String typeId, String content, - String lineSeparator) { + String lineSeparator, + boolean frontendDocument, + VfsDocumentAccessMode accessMode, + boolean dirty) { public EditorOpenFileBuffer { path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); tabLabel = Objects.requireNonNull(tabLabel, "tabLabel"); typeId = Objects.requireNonNull(typeId, "typeId"); content = Objects.requireNonNull(content, "content"); lineSeparator = Objects.requireNonNull(lineSeparator, "lineSeparator"); + Objects.requireNonNull(accessMode, "accessMode"); + } + + public boolean editable() { + return accessMode == VfsDocumentAccessMode.EDITABLE; + } + + public boolean readOnly() { + return !editable(); + } + + public boolean saveEnabled() { + return editable() && dirty; } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java index 6567c89b..454da3a7 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java @@ -39,6 +39,14 @@ public final class EditorOpenFileSession { return find(activePath); } + public boolean hasDirtyEditableFiles() { + return openFiles.stream().anyMatch(EditorOpenFileBuffer::saveEnabled); + } + + public boolean hasSaveableActiveFile() { + return activeFile().map(EditorOpenFileBuffer::saveEnabled).orElse(false); + } + public boolean isEmpty() { return openFiles.isEmpty(); } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java index da1c9aef..d97fe40d 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java @@ -66,7 +66,7 @@ public final class EditorStatusBar extends HBox { bindDefault(indentation, I18n.CODE_EDITOR_STATUS_INDENTATION); setText(language, fileBuffer.typeId()); EditorDocumentPresentationStyles.applyToStatusChip(language, presentation); - bindDefault(readOnly, I18n.CODE_EDITOR_STATUS_READ_ONLY); + showAccessMode(fileBuffer); } public void showPlaceholder(final EditorDocumentPresentation presentation) { @@ -78,6 +78,10 @@ public final class EditorStatusBar extends HBox { bindDefault(language, I18n.CODE_EDITOR_STATUS_LANGUAGE); EditorDocumentPresentationStyles.applyToStatusChip(language, presentation); bindDefault(readOnly, I18n.CODE_EDITOR_STATUS_READ_ONLY); + readOnly.getStyleClass().remove("editor-workspace-status-chip-editable"); + if (!readOnly.getStyleClass().contains("editor-workspace-status-chip-read-only")) { + readOnly.getStyleClass().add("editor-workspace-status-chip-read-only"); + } } private void bindDefault(final Label label, final I18n key) { @@ -174,6 +178,23 @@ public final class EditorStatusBar extends HBox { setVisibleManaged(readOnly, visible); } + private void showAccessMode(final EditorOpenFileBuffer fileBuffer) { + if (fileBuffer.readOnly()) { + bindDefault(readOnly, I18n.CODE_EDITOR_STATUS_READ_ONLY); + readOnly.getStyleClass().remove("editor-workspace-status-chip-editable"); + if (!readOnly.getStyleClass().contains("editor-workspace-status-chip-read-only")) { + readOnly.getStyleClass().add("editor-workspace-status-chip-read-only"); + } + return; + } + + bindDefault(readOnly, I18n.CODE_EDITOR_STATUS_EDITABLE); + readOnly.getStyleClass().remove("editor-workspace-status-chip-read-only"); + if (!readOnly.getStyleClass().contains("editor-workspace-status-chip-editable")) { + readOnly.getStyleClass().add("editor-workspace-status-chip-editable"); + } + } + private void setVisibleManaged(final Label label, final boolean visible) { label.setVisible(visible); label.setManaged(visible); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorTabStrip.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorTabStrip.java index b686d04b..cac22323 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorTabStrip.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorTabStrip.java @@ -85,8 +85,11 @@ public final class EditorTabStrip extends HBox { tabButton.getStyleClass().addAll( "studio-button", "studio-button-secondary", - "editor-workspace-tab-button", - "editor-workspace-tab-button-read-only"); + "editor-workspace-tab-button"); + tabButton.getStyleClass().add( + fileBuffer.readOnly() + ? "editor-workspace-tab-button-read-only" + : "editor-workspace-tab-button-editable"); applyTabMetrics(tabButton); if (fileBuffer.path().equals(activePath)) { tabButton.getStyleClass().add("editor-workspace-tab-button-active"); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java index 4308bb21..80cbe41b 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java @@ -1,22 +1,27 @@ package p.studio.workspaces.editor; -import javafx.scene.control.Alert; import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.Label; import javafx.scene.control.SplitPane; import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; import javafx.scene.layout.VBox; import org.fxmisc.richtext.CodeArea; import org.fxmisc.richtext.LineNumberFactory; import p.studio.projects.ProjectReference; import p.studio.utilities.i18n.I18n; -import p.studio.workspaces.Workspace; -import p.studio.workspaces.WorkspaceId; import p.studio.vfs.ProjectDocumentVfs; import p.studio.vfs.VfsDocumentOpenResult; import p.studio.vfs.VfsProjectNode; import p.studio.vfs.VfsTextDocument; +import p.studio.workspaces.Workspace; +import p.studio.workspaces.WorkspaceId; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -24,6 +29,9 @@ import java.util.Objects; public final class EditorWorkspace extends Workspace { private final BorderPane root = new BorderPane(); private final CodeArea codeArea = new CodeArea(); + private final Button saveButton = new Button(); + private final Button saveAllButton = new Button(); + private final Label readOnlyWarning = new Label(); private final EditorProjectNavigatorPanel navigatorPanel = new EditorProjectNavigatorPanel(); private final EditorOutlinePanel outlinePanel = new EditorOutlinePanel(); private final EditorHelperPanel helperPanel = new EditorHelperPanel(); @@ -33,6 +41,7 @@ public final class EditorWorkspace extends Workspace { private final ProjectDocumentVfs projectDocumentVfs; private final EditorOpenFileSession openFileSession = new EditorOpenFileSession(); private final List activePresentationStylesheets = new ArrayList<>(); + private boolean syncingEditor; public EditorWorkspace( final ProjectReference projectReference, @@ -43,8 +52,11 @@ public final class EditorWorkspace extends Workspace { codeArea.setParagraphGraphicFactory(LineNumberFactory.get(codeArea)); codeArea.setEditable(false); codeArea.setWrapText(false); + codeArea.textProperty().addListener((ignored, previous, current) -> syncActiveDocumentToVfs(current)); codeArea.getStyleClass().add("editor-workspace-code-area"); EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentationRegistry.resolve("text")); + configureCommandBar(); + configureWarning(); showEditorPlaceholder(); navigatorPanel.setRefreshAction(this::refreshNavigator); navigatorPanel.setRevealActiveFileAction(this::revealActiveFileInNavigator); @@ -88,12 +100,7 @@ public final class EditorWorkspace extends Workspace { } private void openFile(final VfsTextDocument textDocument) { - openFileSession.open(new EditorOpenFileBuffer( - textDocument.path(), - textDocument.documentName(), - textDocument.typeId(), - textDocument.content(), - textDocument.lineSeparator())); + openFileSession.open(bufferFrom(textDocument)); renderSession(); } @@ -115,9 +122,16 @@ public final class EditorWorkspace extends Workspace { final var fileBuffer = activeFile.orElseThrow(); final EditorDocumentPresentation presentation = presentationRegistry.resolve(fileBuffer.typeId()); applyPresentationStylesheets(presentation); - codeArea.replaceText(fileBuffer.content()); - codeArea.setStyleSpans(0, presentation.highlight(fileBuffer.content())); + syncingEditor = true; + try { + codeArea.replaceText(fileBuffer.content()); + codeArea.setStyleSpans(0, presentation.highlight(fileBuffer.content())); + } finally { + syncingEditor = false; + } + codeArea.setEditable(fileBuffer.editable()); EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation); + refreshCommandSurfaces(fileBuffer); statusBar.showFile(projectReference, fileBuffer, presentation); } @@ -127,7 +141,7 @@ public final class EditorWorkspace extends Workspace { .ifPresent(navigatorPanel::revealPath); } - private void showUnsupportedFileModal(final java.nio.file.Path path) { + private void showUnsupportedFileModal(final Path path) { final var alert = new Alert(Alert.AlertType.INFORMATION); if (root.getScene() != null) { alert.initOwner(root.getScene().getWindow()); @@ -141,13 +155,23 @@ public final class EditorWorkspace extends Workspace { private void showEditorPlaceholder() { final EditorDocumentPresentation presentation = presentationRegistry.resolve("text"); final String placeholder = """ - // Read-only first wave - // Open a supported text file from the project navigator. + // Controlled write wave + // Open a supported project document from the navigator. """; applyPresentationStylesheets(presentation); - codeArea.replaceText(placeholder); - codeArea.setStyleSpans(0, presentation.highlight(placeholder)); + syncingEditor = true; + try { + codeArea.replaceText(placeholder); + codeArea.setStyleSpans(0, presentation.highlight(placeholder)); + } finally { + syncingEditor = false; + } + codeArea.setEditable(false); EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation); + saveButton.setDisable(true); + saveAllButton.setDisable(true); + readOnlyWarning.setVisible(false); + readOnlyWarning.setManaged(false); } private void applyPresentationStylesheets(final EditorDocumentPresentation presentation) { @@ -175,7 +199,7 @@ public final class EditorWorkspace extends Workspace { } private VBox buildCenterColumn() { - final var centerColumn = new VBox(12, tabStrip, codeArea); + final var centerColumn = new VBox(12, buildCommandBar(), readOnlyWarning, tabStrip, codeArea); centerColumn.getStyleClass().add("editor-workspace-center-column"); VBox.setVgrow(codeArea, Priority.ALWAYS); return centerColumn; @@ -184,4 +208,103 @@ public final class EditorWorkspace extends Workspace { private SplitPane buildRightColumn() { return helperPanel.createBottomDockLayout(buildCenterColumn(), "editor-workspace-right-split"); } + + private void configureCommandBar() { + saveButton.textProperty().bind(p.studio.Container.i18n().bind(I18n.CODE_EDITOR_COMMAND_SAVE)); + saveAllButton.textProperty().bind(p.studio.Container.i18n().bind(I18n.CODE_EDITOR_COMMAND_SAVE_ALL)); + saveButton.getStyleClass().addAll("studio-button", "editor-workspace-command-button"); + saveAllButton.getStyleClass().addAll("studio-button", "studio-button-secondary", "editor-workspace-command-button"); + saveButton.setFocusTraversable(false); + saveAllButton.setFocusTraversable(false); + saveButton.setDisable(true); + saveAllButton.setDisable(true); + saveButton.setOnAction(event -> saveActiveFile()); + saveAllButton.setOnAction(event -> saveAllFiles()); + } + + private void configureWarning() { + readOnlyWarning.textProperty().bind(p.studio.Container.i18n().bind(I18n.CODE_EDITOR_WARNING_FRONTEND_READ_ONLY)); + readOnlyWarning.getStyleClass().add("editor-workspace-warning"); + readOnlyWarning.setWrapText(true); + readOnlyWarning.setVisible(false); + readOnlyWarning.setManaged(false); + } + + private HBox buildCommandBar() { + final var spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + final var commandBar = new HBox(8, saveButton, saveAllButton, spacer); + commandBar.getStyleClass().add("editor-workspace-command-bar"); + return commandBar; + } + + private void syncActiveDocumentToVfs(final String content) { + if (syncingEditor) { + return; + } + openFileSession.activeFile() + .filter(EditorOpenFileBuffer::editable) + .ifPresent(activeFile -> { + final VfsTextDocument updatedDocument = projectDocumentVfs.updateDocument(activeFile.path(), content); + openFileSession.open(bufferFrom(updatedDocument)); + tabStrip.showOpenFiles( + openFileSession.openFiles(), + openFileSession.activeFile().map(EditorOpenFileBuffer::path).orElse(null)); + refreshCommandSurfaces(openFileSession.activeFile().orElseThrow()); + }); + } + + private void saveActiveFile() { + openFileSession.activeFile() + .filter(EditorOpenFileBuffer::saveEnabled) + .ifPresent(activeFile -> { + projectDocumentVfs.saveDocument(activeFile.path()); + reloadOpenFilesFromVfs(activeFile.path()); + renderSession(); + }); + } + + private void saveAllFiles() { + if (!openFileSession.hasDirtyEditableFiles()) { + return; + } + projectDocumentVfs.saveAllDocuments(); + reloadOpenFilesFromVfs(openFileSession.activeFile().map(EditorOpenFileBuffer::path).orElse(null)); + renderSession(); + } + + private void reloadOpenFilesFromVfs(final Path activePath) { + final var openPaths = openFileSession.openFiles().stream() + .map(EditorOpenFileBuffer::path) + .toList(); + for (final var path : openPaths) { + final var result = projectDocumentVfs.openDocument(path); + if (result instanceof VfsTextDocument textDocument) { + openFileSession.open(bufferFrom(textDocument)); + } + } + if (activePath != null) { + openFileSession.activate(activePath); + } + } + + private void refreshCommandSurfaces(final EditorOpenFileBuffer fileBuffer) { + saveButton.setDisable(!fileBuffer.saveEnabled()); + saveAllButton.setDisable(!openFileSession.hasDirtyEditableFiles()); + final boolean showWarning = fileBuffer.frontendDocument() && fileBuffer.readOnly(); + readOnlyWarning.setVisible(showWarning); + readOnlyWarning.setManaged(showWarning); + } + + private EditorOpenFileBuffer bufferFrom(final VfsTextDocument textDocument) { + return new EditorOpenFileBuffer( + textDocument.path(), + textDocument.documentName(), + textDocument.typeId(), + textDocument.content(), + textDocument.lineSeparator(), + textDocument.accessContext().frontendDocument(), + textDocument.accessContext().accessMode(), + textDocument.dirty()); + } } diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index deb38280..8579790f 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -78,7 +78,11 @@ codeEditor.status.position=L:C codeEditor.status.lineSeparator=LF codeEditor.status.indentation=Spaces: 4 codeEditor.status.language=Text +codeEditor.status.editable=Editable codeEditor.status.readOnly=Read-only +codeEditor.command.save=Save +codeEditor.command.saveAll=Save All +codeEditor.warning.frontendReadOnly=This frontend file is read-only in this wave. It cannot be edited or saved yet. codeEditor.unsupportedFile.title=Unsupported file codeEditor.unsupportedFile.message=This file is not supported in this wave: {0} workspace.shipper=Shipper diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index b6f30d8c..d2a6c93a 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -587,6 +587,31 @@ -fx-border-color: #a7d7ff #5c738b #5c738b #5c738b; } +.editor-workspace-tab-button-editable { + -fx-background-color: #203226; + -fx-border-color: #4d6f58; + -fx-text-fill: #e8f6eb; +} + +.editor-workspace-tab-button-editable:hover { + -fx-background-color: #29412f; + -fx-border-color: #6f957a; + -fx-text-fill: #f4fff5; +} + +.editor-workspace-tab-button-editable.editor-workspace-tab-button-active { + -fx-background-color: #1d3a2a; + -fx-border-color: #8ad3a2 #587464 #587464 #587464; + -fx-border-width: 3 1 1 1; + -fx-text-fill: #ffffff; + -fx-font-weight: bold; +} + +.editor-workspace-tab-button-editable.editor-workspace-tab-button-active:hover { + -fx-background-color: #234532; + -fx-border-color: #a5efbd #688676 #688676 #688676; +} + .editor-workspace-tab-overflow { -fx-background-radius: 0; -fx-border-radius: 0; @@ -632,6 +657,26 @@ -fx-fill: #f2f6fb; } +.editor-workspace-command-bar { + -fx-padding: 10 12 0 12; + -fx-alignment: center-left; +} + +.editor-workspace-command-button { + -fx-min-height: 34; + -fx-pref-height: 34; + -fx-max-height: 34; + -fx-padding: 0 14 0 14; +} + +.editor-workspace-warning { + -fx-padding: 8 12 8 12; + -fx-background-color: #3b2a10; + -fx-border-color: #8f6730; + -fx-text-fill: #f7ddb0; + -fx-font-size: 12px; +} + .workspace-dock-pane { -fx-collapsible: true; -fx-background-color: transparent; @@ -782,6 +827,12 @@ -fx-max-width: 88; } +.editor-workspace-status-chip-editable { + -fx-background-color: #203226; + -fx-border-color: #5a8567; + -fx-text-fill: #ebfff0; +} + .assets-workspace-split { -fx-background-color: transparent; } diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorOpenFileSessionTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorOpenFileSessionTest.java index c3ed9200..c2170bfa 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorOpenFileSessionTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorOpenFileSessionTest.java @@ -1,6 +1,7 @@ package p.studio.workspaces.editor; import org.junit.jupiter.api.Test; +import p.studio.vfs.VfsDocumentAccessMode; import java.nio.file.Path; @@ -11,7 +12,7 @@ final class EditorOpenFileSessionTest { @Test void openAddsNewFileAndMarksItActive() { final var session = new EditorOpenFileSession(); - final var file = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "pbs", "fn main(): void\n", "LF"); + final var file = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "fn main(): void\n"); session.open(file); @@ -22,8 +23,8 @@ final class EditorOpenFileSessionTest { @Test void openDoesNotDuplicateExistingTab() { final var session = new EditorOpenFileSession(); - final var first = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "pbs", "a", "LF"); - final var second = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "pbs", "b", "LF"); + final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "a"); + final var second = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "b"); session.open(first); session.open(second); @@ -36,8 +37,8 @@ final class EditorOpenFileSessionTest { @Test void activateSwitchesTheActiveTabWithinTheCurrentSession() { final var session = new EditorOpenFileSession(); - final var first = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "pbs", "a", "LF"); - final var second = new EditorOpenFileBuffer(Path.of("src/other.pbs"), "other.pbs", "pbs", "b", "LF"); + final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "a"); + final var second = fileBuffer(Path.of("src/other.pbs"), "other.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "b"); session.open(first); session.open(second); @@ -45,4 +46,42 @@ final class EditorOpenFileSessionTest { assertEquals(first.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path()); } + + @Test + void reportsSaveStateForDirtyEditableFilesOnly() { + final var session = new EditorOpenFileSession(); + session.open(fileBuffer(Path.of("notes.txt"), "notes.txt", VfsDocumentAccessMode.EDITABLE, true, false, "alpha")); + + assertTrue(session.hasSaveableActiveFile()); + assertTrue(session.hasDirtyEditableFiles()); + } + + @Test + void frontendReadOnlyFilesDoNotEnableSaveActions() { + final var session = new EditorOpenFileSession(); + session.open(fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "fn main(): void")); + + assertFalse(session.hasSaveableActiveFile()); + assertFalse(session.hasDirtyEditableFiles()); + assertTrue(session.activeFile().orElseThrow().frontendDocument()); + assertTrue(session.activeFile().orElseThrow().readOnly()); + } + + private EditorOpenFileBuffer fileBuffer( + final Path path, + final String tabLabel, + final VfsDocumentAccessMode accessMode, + final boolean dirty, + final boolean frontendDocument, + final String content) { + return new EditorOpenFileBuffer( + path, + tabLabel, + frontendDocument ? "pbs" : "text", + content, + "LF", + frontendDocument, + accessMode, + dirty); + } }