diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentPresentation.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentPresentation.java new file mode 100644 index 00000000..40b53b13 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentPresentation.java @@ -0,0 +1,24 @@ +package p.studio.workspaces.editor; + +import org.fxmisc.richtext.model.StyleSpans; +import p.studio.workspaces.editor.syntaxhighlight.EditorDocumentSyntaxHighlighting; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +record EditorDocumentPresentation( + String styleKey, + List stylesheetUrls, + EditorDocumentSyntaxHighlighting syntaxHighlighting) { + + EditorDocumentPresentation { + styleKey = Objects.requireNonNull(styleKey, "styleKey"); + stylesheetUrls = List.copyOf(Objects.requireNonNull(stylesheetUrls, "stylesheetUrls")); + syntaxHighlighting = Objects.requireNonNull(syntaxHighlighting, "syntaxHighlighting"); + } + + StyleSpans> highlight(final String content) { + return syntaxHighlighting.highlight(content); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentPresentationRegistry.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentPresentationRegistry.java new file mode 100644 index 00000000..ff288ee6 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentPresentationRegistry.java @@ -0,0 +1,54 @@ +package p.studio.workspaces.editor; + +import p.studio.compiler.FrontendRegistryService; +import p.studio.vfs.VfsDocumentTypeIds; +import p.studio.workspaces.editor.syntaxhighlight.EditorDocumentSyntaxHighlighting; + +import java.util.Locale; + +final class EditorDocumentPresentationRegistry { + private static final EditorDocumentPresentation TEXT_PRESENTATION = new EditorDocumentPresentation( + "text", + java.util.List.of(), + EditorDocumentSyntaxHighlighting.plainText()); + private static final EditorDocumentPresentation JSON_PRESENTATION = new EditorDocumentPresentation( + "json", + java.util.List.of(stylesheet("presentations/json.css")), + EditorDocumentSyntaxHighlighting.json()); + private static final EditorDocumentPresentation BASH_PRESENTATION = new EditorDocumentPresentation( + "bash", + java.util.List.of(stylesheet("presentations/bash.css")), + EditorDocumentSyntaxHighlighting.bash()); + private static final EditorDocumentPresentation FRONTEND_PRESENTATION = new EditorDocumentPresentation( + "fe", + java.util.List.of(), + EditorDocumentSyntaxHighlighting.plainText()); + + EditorDocumentPresentation resolve(final String typeId) { + final String normalizedTypeId = normalize(typeId); + if (normalizedTypeId.isBlank()) { + return TEXT_PRESENTATION; + } + if (FrontendRegistryService.getFrontendSpec(normalizedTypeId).isPresent()) { + return FRONTEND_PRESENTATION; + } + if (VfsDocumentTypeIds.JSON.equals(normalizedTypeId)) { + return JSON_PRESENTATION; + } + if (VfsDocumentTypeIds.BASH.equals(normalizedTypeId)) { + return BASH_PRESENTATION; + } + return TEXT_PRESENTATION; + } + + private String normalize(final String typeId) { + return typeId == null ? "" : typeId.trim().toLowerCase(Locale.ROOT); + } + + private static String stylesheet(final String relativePath) { + return java.util.Objects.requireNonNull( + EditorDocumentPresentationRegistry.class.getResource("/themes/editor/" + relativePath), + "missing editor presentation stylesheet: " + relativePath) + .toExternalForm(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentPresentationStyles.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentPresentationStyles.java new file mode 100644 index 00000000..cf0bedcb --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentPresentationStyles.java @@ -0,0 +1,28 @@ +package p.studio.workspaces.editor; + +import javafx.scene.Node; +import javafx.scene.control.Label; + +final class EditorDocumentPresentationStyles { + private static final String CODE_AREA_TYPE_CLASS_PREFIX = "editor-workspace-code-area-type-"; + private static final String STATUS_CHIP_TYPE_CLASS_PREFIX = "editor-workspace-status-chip-type-"; + + private EditorDocumentPresentationStyles() { + } + + static void applyToCodeArea(final Node node, final EditorDocumentPresentation presentation) { + replaceTypeClass(node, CODE_AREA_TYPE_CLASS_PREFIX, presentation.styleKey()); + } + + static void applyToStatusChip(final Label label, final EditorDocumentPresentation presentation) { + replaceTypeClass(label, STATUS_CHIP_TYPE_CLASS_PREFIX, presentation.styleKey()); + } + + private static void replaceTypeClass( + final Node node, + final String prefix, + final String styleKey) { + node.getStyleClass().removeIf(styleClass -> styleClass.startsWith(prefix)); + node.getStyleClass().add(prefix + styleKey); + } +} 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 4b6d4730..6cc6ed1d 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 @@ -6,11 +6,13 @@ import java.util.Objects; public record EditorOpenFileBuffer( Path path, String tabLabel, + String typeId, String content, String lineSeparator) { 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"); } 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 7dfbb2d3..da1c9aef 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 @@ -53,26 +53,30 @@ public final class EditorStatusBar extends HBox { readOnly ); - showPlaceholder(); } - public void showFile(final ProjectReference projectReference, final EditorOpenFileBuffer fileBuffer) { + public void showFile( + final ProjectReference projectReference, + final EditorOpenFileBuffer fileBuffer, + final EditorDocumentPresentation presentation) { showBreadcrumb(projectReference, fileBuffer.path()); showMetadata(true); bindDefault(position, I18n.CODE_EDITOR_STATUS_POSITION); setText(lineSeparator, fileBuffer.lineSeparator()); bindDefault(indentation, I18n.CODE_EDITOR_STATUS_INDENTATION); - setText(language, extensionText(fileBuffer.path())); + setText(language, fileBuffer.typeId()); + EditorDocumentPresentationStyles.applyToStatusChip(language, presentation); bindDefault(readOnly, I18n.CODE_EDITOR_STATUS_READ_ONLY); } - public void showPlaceholder() { + public void showPlaceholder(final EditorDocumentPresentation presentation) { breadcrumb.getChildren().clear(); showMetadata(false); bindDefault(position, I18n.CODE_EDITOR_STATUS_POSITION); bindDefault(lineSeparator, I18n.CODE_EDITOR_STATUS_LINE_SEPARATOR); bindDefault(indentation, I18n.CODE_EDITOR_STATUS_INDENTATION); bindDefault(language, I18n.CODE_EDITOR_STATUS_LANGUAGE); + EditorDocumentPresentationStyles.applyToStatusChip(language, presentation); bindDefault(readOnly, I18n.CODE_EDITOR_STATUS_READ_ONLY); } @@ -180,12 +184,4 @@ public final class EditorStatusBar extends HBox { label.getStyleClass().add("editor-workspace-status-chip"); } } - - private String extensionText(final java.nio.file.Path path) { - final var fileName = path.getFileName().toString(); - final var dot = fileName.lastIndexOf('.'); - return dot >= 0 && dot < fileName.length() - 1 - ? fileName.substring(dot + 1) - : fileName; - } } 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 5c56d681..4308bb21 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 @@ -17,6 +17,8 @@ import p.studio.vfs.VfsDocumentOpenResult; import p.studio.vfs.VfsProjectNode; import p.studio.vfs.VfsTextDocument; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; public final class EditorWorkspace extends Workspace { @@ -27,8 +29,10 @@ public final class EditorWorkspace extends Workspace { private final EditorHelperPanel helperPanel = new EditorHelperPanel(); private final EditorStatusBar statusBar = new EditorStatusBar(); private final EditorTabStrip tabStrip = new EditorTabStrip(); + private final EditorDocumentPresentationRegistry presentationRegistry = new EditorDocumentPresentationRegistry(); private final ProjectDocumentVfs projectDocumentVfs; private final EditorOpenFileSession openFileSession = new EditorOpenFileSession(); + private final List activePresentationStylesheets = new ArrayList<>(); public EditorWorkspace( final ProjectReference projectReference, @@ -40,6 +44,7 @@ public final class EditorWorkspace extends Workspace { codeArea.setEditable(false); codeArea.setWrapText(false); codeArea.getStyleClass().add("editor-workspace-code-area"); + EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentationRegistry.resolve("text")); showEditorPlaceholder(); navigatorPanel.setRefreshAction(this::refreshNavigator); navigatorPanel.setRevealActiveFileAction(this::revealActiveFileInNavigator); @@ -50,7 +55,7 @@ public final class EditorWorkspace extends Workspace { }); root.setCenter(buildLayout()); - statusBar.showPlaceholder(); + statusBar.showPlaceholder(presentationRegistry.resolve("text")); } @Override public WorkspaceId workspaceId() { return WorkspaceId.EDITOR; } @@ -86,6 +91,7 @@ public final class EditorWorkspace extends Workspace { openFileSession.open(new EditorOpenFileBuffer( textDocument.path(), textDocument.documentName(), + textDocument.typeId(), textDocument.content(), textDocument.lineSeparator())); renderSession(); @@ -99,13 +105,20 @@ public final class EditorWorkspace extends Workspace { activeFile.map(EditorOpenFileBuffer::path).orElse(null)); if (activeFile.isEmpty()) { showEditorPlaceholder(); - statusBar.showPlaceholder(); + final EditorDocumentPresentation presentation = presentationRegistry.resolve("text"); + applyPresentationStylesheets(presentation); + EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation); + statusBar.showPlaceholder(presentation); return; } final var fileBuffer = activeFile.orElseThrow(); + final EditorDocumentPresentation presentation = presentationRegistry.resolve(fileBuffer.typeId()); + applyPresentationStylesheets(presentation); codeArea.replaceText(fileBuffer.content()); - statusBar.showFile(projectReference, fileBuffer); + codeArea.setStyleSpans(0, presentation.highlight(fileBuffer.content())); + EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation); + statusBar.showFile(projectReference, fileBuffer, presentation); } private void revealActiveFileInNavigator() { @@ -126,10 +139,22 @@ public final class EditorWorkspace extends Workspace { } private void showEditorPlaceholder() { - codeArea.replaceText(""" + final EditorDocumentPresentation presentation = presentationRegistry.resolve("text"); + final String placeholder = """ // Read-only first wave // Open a supported text file from the project navigator. - """); + """; + applyPresentationStylesheets(presentation); + codeArea.replaceText(placeholder); + codeArea.setStyleSpans(0, presentation.highlight(placeholder)); + EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation); + } + + private void applyPresentationStylesheets(final EditorDocumentPresentation presentation) { + root.getStylesheets().removeAll(activePresentationStylesheets); + activePresentationStylesheets.clear(); + activePresentationStylesheets.addAll(presentation.stylesheetUrls()); + root.getStylesheets().addAll(activePresentationStylesheets); } private VBox buildLayout() { diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentHighlightToken.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentHighlightToken.java new file mode 100644 index 00000000..fc1c8c9d --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentHighlightToken.java @@ -0,0 +1,25 @@ +package p.studio.workspaces.editor.syntaxhighlight; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +public record EditorDocumentHighlightToken( + String groupName, + Collection styleClasses) { + + public EditorDocumentHighlightToken(final String groupName, final String styleClass) { + this(groupName, List.of(styleClass)); + } + + public EditorDocumentHighlightToken { + groupName = Objects.requireNonNull(groupName, "groupName"); + styleClasses = List.copyOf(Objects.requireNonNull(styleClasses, "styleClasses")); + if (groupName.isBlank()) { + throw new IllegalArgumentException("groupName cannot be blank"); + } + if (styleClasses.isEmpty()) { + throw new IllegalArgumentException("styleClasses cannot be empty"); + } + } +} \ No newline at end of file diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlighting.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlighting.java new file mode 100644 index 00000000..cb59a654 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlighting.java @@ -0,0 +1,67 @@ +package p.studio.workspaces.editor.syntaxhighlight; + +import org.fxmisc.richtext.model.StyleSpans; +import org.fxmisc.richtext.model.StyleSpansBuilder; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public record EditorDocumentSyntaxHighlighting( + Pattern tokenPattern, + List tokens) { + + public EditorDocumentSyntaxHighlighting { + tokens = List.copyOf(Objects.requireNonNull(tokens, "tokens")); + if (tokenPattern == null && !tokens.isEmpty()) { + throw new IllegalArgumentException("plain text highlighting cannot define tokens"); + } + if (tokenPattern != null && tokens.isEmpty()) { + throw new IllegalArgumentException("token-based highlighting requires tokens"); + } + } + + public static EditorDocumentSyntaxHighlighting plainText() { + return EditorDocumentSyntaxHighlightingPlainText.PLAIN_TEXT; + } + + public static EditorDocumentSyntaxHighlighting json() { + return EditorDocumentSyntaxHighlightingJson.JSON; + } + + public static EditorDocumentSyntaxHighlighting bash() { + return EditorDocumentSyntaxHighlightingBash.BASH; + } + + public StyleSpans> highlight(final String content) { + final StyleSpansBuilder> builder = new StyleSpansBuilder<>(); + if (tokenPattern == null) { + builder.add(Collections.emptyList(), content.length()); + return builder.create(); + } + + final Matcher matcher = tokenPattern.matcher(content); + int lastMatchEnd = 0; + + while (matcher.find()) { + builder.add(Collections.emptyList(), matcher.start() - lastMatchEnd); + builder.add(styleClassFor(matcher), matcher.end() - matcher.start()); + lastMatchEnd = matcher.end(); + } + + builder.add(Collections.emptyList(), content.length() - lastMatchEnd); + return builder.create(); + } + + private Collection styleClassFor(final Matcher matcher) { + for (final EditorDocumentHighlightToken token : tokens) { + if (matcher.group(token.groupName()) != null) { + return token.styleClasses(); + } + } + throw new IllegalStateException("matched token without registered style"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingBash.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingBash.java new file mode 100644 index 00000000..e884e6f6 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingBash.java @@ -0,0 +1,26 @@ +package p.studio.workspaces.editor.syntaxhighlight; + +import java.util.List; +import java.util.regex.Pattern; + +public class EditorDocumentSyntaxHighlightingBash { + public static final EditorDocumentSyntaxHighlighting BASH = new EditorDocumentSyntaxHighlighting( + Pattern.compile( + "(?^#![^\\n]*)" + + "|(?(?m)(?\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*')" + + "|(?\\$\\{?[A-Za-z_][A-Za-z0-9_]*}?|\\$[0-9@*#?!$-])" + + "|(?(?m)^[ \\t]*[A-Za-z_./-][A-Za-z0-9_./-]*)" + + "|(?\\b(?:if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|time)\\b)" + + "|(?\\b(?:echo|printf|read|cd|pwd|export|local|readonly|unset|return|shift|source|trap|exit|test)\\b)" + + "|(?\\|\\||&&|;;|<<-?|>>|[|&;<>~=(){}\\[\\]])"), + List.of( + new EditorDocumentHighlightToken("SHEBANG", "editor-syntax-bash-shebang"), + new EditorDocumentHighlightToken("COMMENT", "editor-syntax-bash-comment"), + new EditorDocumentHighlightToken("STRING", "editor-syntax-bash-string"), + new EditorDocumentHighlightToken("VARIABLE", "editor-syntax-bash-variable"), + new EditorDocumentHighlightToken("KEYWORD", "editor-syntax-bash-keyword"), + new EditorDocumentHighlightToken("BUILTIN", "editor-syntax-bash-builtin"), + new EditorDocumentHighlightToken("COMMAND", "editor-syntax-bash-command"), + new EditorDocumentHighlightToken("OPERATOR", "editor-syntax-bash-operator"))); +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingJson.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingJson.java new file mode 100644 index 00000000..58f803e8 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingJson.java @@ -0,0 +1,22 @@ +package p.studio.workspaces.editor.syntaxhighlight; + +import java.util.List; +import java.util.regex.Pattern; + +public class EditorDocumentSyntaxHighlightingJson { + public static final EditorDocumentSyntaxHighlighting JSON = new EditorDocumentSyntaxHighlighting( + Pattern.compile( + "(?\"(?:\\\\.|[^\"\\\\])*\"(?=\\s*:))" + + "|(?\"(?:\\\\.|[^\"\\\\])*\")" + + "|(?-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b)" + + "|(?\\b(?:true|false)\\b)" + + "|(?\\bnull\\b)" + + "|(?[\\{\\}\\[\\],:])"), + List.of( + new EditorDocumentHighlightToken("KEY", "editor-syntax-json-key"), + new EditorDocumentHighlightToken("STRING", "editor-syntax-json-string"), + new EditorDocumentHighlightToken("NUMBER", "editor-syntax-json-number"), + new EditorDocumentHighlightToken("BOOLEAN", "editor-syntax-json-boolean"), + new EditorDocumentHighlightToken("NULL", "editor-syntax-json-null"), + new EditorDocumentHighlightToken("PUNCTUATION", "editor-syntax-json-punctuation"))); +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingPlainText.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingPlainText.java new file mode 100644 index 00000000..1f13091b --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingPlainText.java @@ -0,0 +1,9 @@ +package p.studio.workspaces.editor.syntaxhighlight; + +import java.util.List; + +public class EditorDocumentSyntaxHighlightingPlainText { + public static final EditorDocumentSyntaxHighlighting PLAIN_TEXT = new EditorDocumentSyntaxHighlighting( + null, + List.of()); +} diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index 6cb249b4..b6f30d8c 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -536,6 +536,10 @@ .editor-workspace-code-area { -fx-background-color: #171c22; + -fx-font-family: "JetBrains Mono", "Iosevka", "Cascadia Mono", "IBM Plex Mono", monospace; + -fx-font-size: 14px; + -fx-highlight-fill: #26405c; + -fx-highlight-text-fill: #eef4fb; } .editor-workspace-tab-button { @@ -606,6 +610,28 @@ -fx-background-color: #171c22; } +.editor-workspace-code-area .paragraph-box { + -fx-background-color: #171c22; +} + +.editor-workspace-code-area .lineno { + -fx-background-color: #12161c; + -fx-text-fill: #6f7a86; + -fx-padding: 0 12 0 12; +} + +.editor-workspace-code-area .text { + -fx-fill: #f2f6fb; +} + +.editor-workspace-code-area-type-text .text { + -fx-fill: #eef3f8; +} + +.editor-workspace-code-area-type-fe .text { + -fx-fill: #f2f6fb; +} + .workspace-dock-pane { -fx-collapsible: true; -fx-background-color: transparent; @@ -714,6 +740,18 @@ -fx-max-height: 28; } +.editor-workspace-status-chip-type-text { + -fx-background-color: #11151b; + -fx-border-color: #2a313c; + -fx-text-fill: #c5d2de; +} + +.editor-workspace-status-chip-type-fe { + -fx-background-color: #271a35; + -fx-border-color: #8d67c7; + -fx-text-fill: #f3ecff; +} + .editor-workspace-status-chip-position { -fx-min-width: 44; -fx-pref-width: 44; diff --git a/prometeu-studio/src/main/resources/themes/editor/presentations/bash.css b/prometeu-studio/src/main/resources/themes/editor/presentations/bash.css new file mode 100644 index 00000000..a3e2c6a9 --- /dev/null +++ b/prometeu-studio/src/main/resources/themes/editor/presentations/bash.css @@ -0,0 +1,39 @@ +.editor-workspace-code-area-type-bash .text { + -fx-fill: #edf2f7; +} + +.editor-workspace-code-area-type-bash .lineno { + -fx-text-fill: #788595; +} + +.editor-workspace-code-area-type-bash .text.editor-syntax-bash-shebang { + -fx-fill: #f0ae63; +} + +.editor-workspace-code-area-type-bash .text.editor-syntax-bash-comment { + -fx-fill: #7d8a98; +} + +.editor-workspace-code-area-type-bash .text.editor-syntax-bash-string { + -fx-fill: #dcbf88; +} + +.editor-workspace-code-area-type-bash .text.editor-syntax-bash-variable { + -fx-fill: #8fd4ff; +} + +.editor-workspace-code-area-type-bash .text.editor-syntax-bash-keyword { + -fx-fill: #d7a6ff; +} + +.editor-workspace-code-area-type-bash .text.editor-syntax-bash-builtin { + -fx-fill: #9fe2a0; +} + +.editor-workspace-code-area-type-bash .text.editor-syntax-bash-command { + -fx-fill: #7fc1ff; +} + +.editor-workspace-code-area-type-bash .text.editor-syntax-bash-operator { + -fx-fill: #c7d3de; +} diff --git a/prometeu-studio/src/main/resources/themes/editor/presentations/json.css b/prometeu-studio/src/main/resources/themes/editor/presentations/json.css new file mode 100644 index 00000000..fa180ac1 --- /dev/null +++ b/prometeu-studio/src/main/resources/themes/editor/presentations/json.css @@ -0,0 +1,41 @@ +.editor-workspace-code-area-type-json { + -fx-highlight-fill: #204766; +} + +.editor-workspace-code-area-type-json .text { + -fx-fill: #dff3ff; +} + +.editor-workspace-code-area-type-json .lineno { + -fx-text-fill: #7e92a6; +} + +.editor-workspace-code-area-type-json .text.editor-syntax-json-key { + -fx-fill: #8fd4ff; +} + +.editor-workspace-code-area-type-json .text.editor-syntax-json-string { + -fx-fill: #d9c48f; +} + +.editor-workspace-code-area-type-json .text.editor-syntax-json-number { + -fx-fill: #9ee39f; +} + +.editor-workspace-code-area-type-json .text.editor-syntax-json-boolean { + -fx-fill: #f3a6d6; +} + +.editor-workspace-code-area-type-json .text.editor-syntax-json-null { + -fx-fill: #c89cff; +} + +.editor-workspace-code-area-type-json .text.editor-syntax-json-punctuation { + -fx-fill: #c8d5e2; +} + +.editor-workspace-status-chip-type-json { + -fx-background-color: #132433; + -fx-border-color: #4e88b8; + -fx-text-fill: #e2f4ff; +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentPresentationRegistryTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentPresentationRegistryTest.java new file mode 100644 index 00000000..7f6379e8 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentPresentationRegistryTest.java @@ -0,0 +1,35 @@ +package p.studio.workspaces.editor; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class EditorDocumentPresentationRegistryTest { + private final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry(); + + @Test + void resolvesFrontendTypeIdsToFrontendPresentation() { + assertEquals("fe", registry.resolve("pbs").styleKey()); + } + + @Test + void resolvesJsonTypeIdsToJsonPresentation() { + final EditorDocumentPresentation presentation = registry.resolve("json"); + + assertEquals("json", presentation.styleKey()); + assertEquals(1, presentation.stylesheetUrls().size()); + } + + @Test + void resolvesBashTypeIdsToBashPresentation() { + final EditorDocumentPresentation presentation = registry.resolve("bash"); + + assertEquals("bash", presentation.styleKey()); + assertEquals(1, presentation.stylesheetUrls().size()); + } + + @Test + void fallsBackToTextPresentationForUnknownTypeIds() { + assertEquals("text", registry.resolve("markdown").styleKey()); + } +} 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 cade85be..c3ed9200 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 @@ -11,7 +11,7 @@ final class EditorOpenFileSessionTest { @Test void openAddsNewFileAndMarksItActive() { final var session = new EditorOpenFileSession(); - final var file = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "fn main(): void\n", "LF"); + final var file = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "pbs", "fn main(): void\n", "LF"); session.open(file); @@ -22,8 +22,8 @@ final class EditorOpenFileSessionTest { @Test void openDoesNotDuplicateExistingTab() { final var session = new EditorOpenFileSession(); - final var first = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "a", "LF"); - final var second = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "b", "LF"); + 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"); session.open(first); session.open(second); @@ -36,8 +36,8 @@ final class EditorOpenFileSessionTest { @Test void activateSwitchesTheActiveTabWithinTheCurrentSession() { final var session = new EditorOpenFileSession(); - final var first = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "a", "LF"); - final var second = new EditorOpenFileBuffer(Path.of("src/other.pbs"), "other.pbs", "b", "LF"); + 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"); session.open(first); session.open(second); diff --git a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentTypeIds.java b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentTypeIds.java new file mode 100644 index 00000000..a6cce851 --- /dev/null +++ b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentTypeIds.java @@ -0,0 +1,10 @@ +package p.studio.vfs; + +public final class VfsDocumentTypeIds { + public static final String TEXT = "text"; + public static final String JSON = "json"; + public static final String BASH = "bash"; + + private VfsDocumentTypeIds() { + } +} diff --git a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsTextDocument.java b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsTextDocument.java index 7ac3a66c..cf91b267 100644 --- a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsTextDocument.java +++ b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsTextDocument.java @@ -6,12 +6,14 @@ import java.util.Objects; public record VfsTextDocument( Path path, String documentName, + String typeId, String content, String lineSeparator) implements VfsDocumentOpenResult { public VfsTextDocument { path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); Objects.requireNonNull(documentName, "documentName"); + Objects.requireNonNull(typeId, "typeId"); Objects.requireNonNull(content, "content"); Objects.requireNonNull(lineSeparator, "lineSeparator"); } diff --git a/prometeu-vfs/prometeu-vfs-v1/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java b/prometeu-vfs/prometeu-vfs-v1/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java index 91e23216..2357c596 100644 --- a/prometeu-vfs/prometeu-vfs-v1/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java +++ b/prometeu-vfs/prometeu-vfs-v1/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java @@ -1,6 +1,7 @@ package p.studio.vfs; import p.studio.compiler.FrontendRegistryService; +import p.studio.compiler.models.FrontendSpec; import java.io.IOException; import java.io.UncheckedIOException; @@ -12,6 +13,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -98,6 +100,7 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs { return new VfsTextDocument( normalizedPath, normalizedPath.getFileName().toString(), + documentTypeId(normalizedPath, content), content, lineSeparator(content)); } @@ -118,6 +121,20 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs { .collect(Collectors.toSet()); } + private String documentTypeId(final Path path, final String content) { + final String extension = extensionOf(path); + if ("json".equals(extension) || "ndjson".equals(extension)) { + return VfsDocumentTypeIds.JSON; + } + if (isBashDocument(path, extension, content)) { + return VfsDocumentTypeIds.BASH; + } + if (isFrontendSourceDocument(extension)) { + return projectContext.languageId(); + } + return VfsDocumentTypeIds.TEXT; + } + private VfsProjectNode buildNode( final Path path, final String displayName, @@ -194,6 +211,39 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs { return Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); } + private boolean isFrontendSourceDocument(final String extension) { + if (extension.isBlank()) { + return false; + } + return FrontendRegistryService.getFrontendSpec(projectContext.languageId()) + .map(FrontendSpec::getAllowedExtensions) + .stream() + .flatMap(allowedExtensions -> allowedExtensions.stream()) + .anyMatch(allowedExtension -> allowedExtension.equalsIgnoreCase(extension)); + } + + private boolean isBashDocument(final Path path, final String extension, final String content) { + if (Set.of("sh", "bash", "bashrc", "bash_profile").contains(extension)) { + return true; + } + final String fileName = path.getFileName().toString().toLowerCase(Locale.ROOT); + if (".bashrc".equals(fileName) || ".bash_profile".equals(fileName)) { + return true; + } + final int firstLineBreak = content.indexOf('\n'); + final String firstLine = firstLineBreak >= 0 ? content.substring(0, firstLineBreak) : content; + return firstLine.startsWith("#!") && (firstLine.contains("bash") || firstLine.contains("/sh")); + } + + private String extensionOf(final Path path) { + final String fileName = path.getFileName().toString(); + final int dot = fileName.lastIndexOf('.'); + if (dot < 0 || dot == fileName.length() - 1) { + return ""; + } + return fileName.substring(dot + 1).toLowerCase(Locale.ROOT); + } + private boolean containsNulByte(final byte[] bytes) { for (final byte value : bytes) { if (value == 0) { diff --git a/prometeu-vfs/prometeu-vfs-v1/src/test/java/p/studio/vfs/FilesystemProjectDocumentVfsTest.java b/prometeu-vfs/prometeu-vfs-v1/src/test/java/p/studio/vfs/FilesystemProjectDocumentVfsTest.java index 6a783615..3657d560 100644 --- a/prometeu-vfs/prometeu-vfs-v1/src/test/java/p/studio/vfs/FilesystemProjectDocumentVfsTest.java +++ b/prometeu-vfs/prometeu-vfs-v1/src/test/java/p/studio/vfs/FilesystemProjectDocumentVfsTest.java @@ -43,10 +43,50 @@ final class FilesystemProjectDocumentVfsTest { final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result); assertEquals("main.pbs", document.documentName()); + assertEquals("pbs", document.typeId()); assertEquals("LF", document.lineSeparator()); assertTrue(document.content().contains("fn main()")); } + @Test + void openDocumentClassifiesJsonFilesWithJsonTypeIdentifier() throws Exception { + final Path file = tempDir.resolve("prometeu.json"); + Files.writeString(file, "{\n \"name\": \"Example\"\n}\n"); + + final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext()); + + final VfsDocumentOpenResult result = vfs.openDocument(file); + final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result); + + assertEquals(VfsDocumentTypeIds.JSON, document.typeId()); + } + + @Test + void openDocumentClassifiesNdjsonFilesWithJsonTypeIdentifier() throws Exception { + final Path file = tempDir.resolve("events.ndjson"); + Files.writeString(file, "{\"event\":\"start\"}\n{\"event\":\"done\"}\n"); + + final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext()); + + final VfsDocumentOpenResult result = vfs.openDocument(file); + final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result); + + assertEquals(VfsDocumentTypeIds.JSON, document.typeId()); + } + + @Test + void openDocumentClassifiesBashScriptsWithBashTypeIdentifier() throws Exception { + final Path file = tempDir.resolve("script.sh"); + Files.writeString(file, "#!/usr/bin/env bash\nprintf \"hi\"\n"); + + final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext()); + + final VfsDocumentOpenResult result = vfs.openDocument(file); + final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result); + + assertEquals(VfsDocumentTypeIds.BASH, document.typeId()); + } + @Test void openDocumentRejectsBinaryLikeFiles() throws Exception { final Path file = tempDir.resolve("sprite.bin"); diff --git a/test-projects/fragments/.studio/activities.json b/test-projects/fragments/.studio/activities.json index 673b3381..9bab5ca2 100644 --- a/test-projects/fragments/.studio/activities.json +++ b/test-projects/fragments/.studio/activities.json @@ -208,6 +208,126 @@ "message" : "Asset scan started", "severity" : "INFO", "sticky" : false +}, { + "source" : "Assets", + "message" : "0 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "0 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "0 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "0 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "0 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "0 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "0 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "0 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "0 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "0 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "0 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "0 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false }, { "source" : "Studio", "message" : "Project ready",