implements PLN-0014
This commit is contained in:
parent
c7ca603731
commit
c6286f13a5
@ -8,5 +8,5 @@
|
|||||||
{"type":"discussion","id":"DSC-0007","status":"done","ticket":"pbs-learn-to-discussion-lessons-migration","title":"Migrate PBS Learn Documents into Discussion Lessons","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["compiler","pbs","migration","discussion-framework","lessons","learn-prune"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0018","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0018-pbs-ast-and-parser-contract-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0019","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0019-pbs-name-resolution-and-linking-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0020","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0020-pbs-runtime-values-identity-memory-boundaries-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0021","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0021-pbs-diagnostics-and-conformance-governance-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0022","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0022-pbs-globals-lifecycle-and-published-entrypoint-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
|
{"type":"discussion","id":"DSC-0007","status":"done","ticket":"pbs-learn-to-discussion-lessons-migration","title":"Migrate PBS Learn Documents into Discussion Lessons","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["compiler","pbs","migration","discussion-framework","lessons","learn-prune"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0018","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0018-pbs-ast-and-parser-contract-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0019","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0019-pbs-name-resolution-and-linking-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0020","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0020-pbs-runtime-values-identity-memory-boundaries-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0021","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0021-pbs-diagnostics-and-conformance-governance-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0022","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0022-pbs-globals-lifecycle-and-published-entrypoint-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
|
||||||
{"type":"discussion","id":"DSC-0008","status":"done","ticket":"pbs-low-level-asset-manager-surface","title":"PBS Low-Level Asset Manager Surface for Runtime AssetManager","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["compiler","pbs","runtime","asset-manager","host-abi","stdlib","asset"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0023","file":"discussion/lessons/DSC-0008-pbs-low-level-asset-manager-surface/LSN-0023-lowassets-runtime-aligned-sdk-surface.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
|
{"type":"discussion","id":"DSC-0008","status":"done","ticket":"pbs-low-level-asset-manager-surface","title":"PBS Low-Level Asset Manager Surface for Runtime AssetManager","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["compiler","pbs","runtime","asset-manager","host-abi","stdlib","asset"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0023","file":"discussion/lessons/DSC-0008-pbs-low-level-asset-manager-surface/LSN-0023-lowassets-runtime-aligned-sdk-surface.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
|
||||||
{"type":"discussion","id":"DSC-0009","status":"open","ticket":"studio-debugger-workspace-integration","title":"Integrate ../debugger into Studio as a dedicated workspace","created_at":"2026-03-30","updated_at":"2026-03-30","tags":["studio","debugger","workspace","integration","shell"],"agendas":[{"id":"AGD-0009","file":"AGD-0009-studio-debugger-workspace-integration.md","status":"open","created_at":"2026-03-30","updated_at":"2026-03-30"}],"decisions":[],"plans":[],"lessons":[]}
|
{"type":"discussion","id":"DSC-0009","status":"open","ticket":"studio-debugger-workspace-integration","title":"Integrate ../debugger into Studio as a dedicated workspace","created_at":"2026-03-30","updated_at":"2026-03-30","tags":["studio","debugger","workspace","integration","shell"],"agendas":[{"id":"AGD-0009","file":"AGD-0009-studio-debugger-workspace-integration.md","status":"open","created_at":"2026-03-30","updated_at":"2026-03-30"}],"decisions":[],"plans":[],"lessons":[]}
|
||||||
{"type":"discussion","id":"DSC-0010","status":"in_progress","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-30","tags":["studio","editor","workspace","multi-frontend","lsp-deferred"],"agendas":[{"id":"AGD-0010","file":"AGD-0010-studio-code-editor-workspace-foundations.md","status":"accepted","created_at":"2026-03-30","updated_at":"2026-03-30"}],"decisions":[{"id":"DEC-0008","file":"DEC-0008-studio-code-editor-read-only-workspace-foundations.md","status":"accepted","created_at":"2026-03-30","updated_at":"2026-03-30","ref_agenda":"AGD-0010"}],"plans":[{"id":"PLN-0012","file":"PLN-0012-studio-code-editor-spec-propagation.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30","ref_decisions":["DEC-0008"]},{"id":"PLN-0013","file":"PLN-0013-editor-workspace-layout-and-passive-surfaces.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30","ref_decisions":["DEC-0008"]},{"id":"PLN-0014","file":"PLN-0014-project-navigator-snapshot-and-read-only-file-opening.md","status":"open","created_at":"2026-03-30","updated_at":"2026-03-30","ref_decisions":["DEC-0008"]}],"lessons":[]}
|
{"type":"discussion","id":"DSC-0010","status":"in_progress","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-30","tags":["studio","editor","workspace","multi-frontend","lsp-deferred"],"agendas":[{"id":"AGD-0010","file":"AGD-0010-studio-code-editor-workspace-foundations.md","status":"accepted","created_at":"2026-03-30","updated_at":"2026-03-30"}],"decisions":[{"id":"DEC-0008","file":"DEC-0008-studio-code-editor-read-only-workspace-foundations.md","status":"accepted","created_at":"2026-03-30","updated_at":"2026-03-30","ref_agenda":"AGD-0010"}],"plans":[{"id":"PLN-0012","file":"PLN-0012-studio-code-editor-spec-propagation.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30","ref_decisions":["DEC-0008"]},{"id":"PLN-0013","file":"PLN-0013-editor-workspace-layout-and-passive-surfaces.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30","ref_decisions":["DEC-0008"]},{"id":"PLN-0014","file":"PLN-0014-project-navigator-snapshot-and-read-only-file-opening.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30","ref_decisions":["DEC-0008"]}],"lessons":[]}
|
||||||
{"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-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"}]}
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
id: PLN-0014
|
id: PLN-0014
|
||||||
ticket: studio-code-editor-workspace-foundations
|
ticket: studio-code-editor-workspace-foundations
|
||||||
title: Implement the Project Navigator snapshot and read-only file opening flow
|
title: Implement the Project Navigator snapshot and read-only file opening flow
|
||||||
status: open
|
status: done
|
||||||
created: 2026-03-30
|
created: 2026-03-30
|
||||||
completed:
|
completed: 2026-03-30
|
||||||
tags:
|
tags:
|
||||||
- studio
|
- studio
|
||||||
- editor
|
- editor
|
||||||
@ -130,11 +130,11 @@ When a navigator selection targets an unsupported file type, show a simple modal
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] The editor has a structural navigator snapshot that covers the whole project and tags frontend-relevant roots.
|
- [x] The editor has a structural navigator snapshot that covers the whole project and tags frontend-relevant roots.
|
||||||
- [ ] The navigator performs initial refresh and supports manual refresh without watcher dependency.
|
- [x] The navigator performs initial refresh and supports manual refresh without watcher dependency.
|
||||||
- [ ] Supported files open into read-only tabs with responsive/overflow behavior preserved.
|
- [x] Supported files open into read-only tabs with responsive/overflow behavior preserved.
|
||||||
- [ ] Unsupported files trigger a simple modal instead of a partial preview.
|
- [x] Unsupported files trigger a simple modal instead of a partial preview.
|
||||||
- [ ] Opened-file content and visual tab state remain session-local only.
|
- [x] Opened-file content and visual tab state remain session-local only.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@ -87,6 +87,8 @@ public enum I18n {
|
|||||||
CODE_EDITOR_STATUS_INDENTATION("codeEditor.status.indentation"),
|
CODE_EDITOR_STATUS_INDENTATION("codeEditor.status.indentation"),
|
||||||
CODE_EDITOR_STATUS_LANGUAGE("codeEditor.status.language"),
|
CODE_EDITOR_STATUS_LANGUAGE("codeEditor.status.language"),
|
||||||
CODE_EDITOR_STATUS_READ_ONLY("codeEditor.status.readOnly"),
|
CODE_EDITOR_STATUS_READ_ONLY("codeEditor.status.readOnly"),
|
||||||
|
CODE_EDITOR_UNSUPPORTED_FILE_TITLE("codeEditor.unsupportedFile.title"),
|
||||||
|
CODE_EDITOR_UNSUPPORTED_FILE_MESSAGE("codeEditor.unsupportedFile.message"),
|
||||||
|
|
||||||
WORKSPACE_SHIPPER("workspace.shipper"),
|
WORKSPACE_SHIPPER("workspace.shipper"),
|
||||||
WORKSPACE_SHIPPER_LOGS("workspace.shipper.logs"),
|
WORKSPACE_SHIPPER_LOGS("workspace.shipper.logs"),
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
package p.studio.workspaces.editor;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.charset.CharacterCodingException;
|
||||||
|
import java.nio.charset.CodingErrorAction;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public final class EditorFileBufferLoader {
|
||||||
|
|
||||||
|
public Optional<EditorOpenFileBuffer> load(final Path path) {
|
||||||
|
final var normalizedPath = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
|
||||||
|
if (!Files.isRegularFile(normalizedPath)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
final byte[] bytes;
|
||||||
|
try {
|
||||||
|
bytes = Files.readAllBytes(normalizedPath);
|
||||||
|
} catch (IOException ioException) {
|
||||||
|
throw new UncheckedIOException(ioException);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containsNulByte(bytes)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
final String content;
|
||||||
|
try {
|
||||||
|
content = StandardCharsets.UTF_8.newDecoder()
|
||||||
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
|
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||||
|
.decode(ByteBuffer.wrap(bytes))
|
||||||
|
.toString();
|
||||||
|
} catch (CharacterCodingException codingException) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.of(new EditorOpenFileBuffer(
|
||||||
|
normalizedPath,
|
||||||
|
normalizedPath.getFileName().toString(),
|
||||||
|
content,
|
||||||
|
lineSeparator(content)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean containsNulByte(final byte[] bytes) {
|
||||||
|
for (final byte value : bytes) {
|
||||||
|
if (value == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String lineSeparator(final String content) {
|
||||||
|
return content.contains("\r\n") ? "CRLF" : "LF";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package p.studio.workspaces.editor;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record EditorOpenFileBuffer(
|
||||||
|
Path path,
|
||||||
|
String tabLabel,
|
||||||
|
String content,
|
||||||
|
String lineSeparator) {
|
||||||
|
public EditorOpenFileBuffer {
|
||||||
|
path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
|
||||||
|
tabLabel = Objects.requireNonNull(tabLabel, "tabLabel");
|
||||||
|
content = Objects.requireNonNull(content, "content");
|
||||||
|
lineSeparator = Objects.requireNonNull(lineSeparator, "lineSeparator");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
package p.studio.workspaces.editor;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public final class EditorOpenFileSession {
|
||||||
|
private final List<EditorOpenFileBuffer> openFiles = new ArrayList<>();
|
||||||
|
private Path activePath;
|
||||||
|
|
||||||
|
public void open(final EditorOpenFileBuffer fileBuffer) {
|
||||||
|
final var buffer = Objects.requireNonNull(fileBuffer, "fileBuffer");
|
||||||
|
final var existingIndex = indexOf(buffer.path());
|
||||||
|
if (existingIndex < 0) {
|
||||||
|
openFiles.add(buffer);
|
||||||
|
} else {
|
||||||
|
openFiles.set(existingIndex, buffer);
|
||||||
|
}
|
||||||
|
activePath = buffer.path();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void activate(final Path path) {
|
||||||
|
final var normalizedPath = normalize(path);
|
||||||
|
if (find(normalizedPath).isPresent()) {
|
||||||
|
activePath = normalizedPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<EditorOpenFileBuffer> openFiles() {
|
||||||
|
return List.copyOf(openFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<EditorOpenFileBuffer> activeFile() {
|
||||||
|
if (activePath == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return find(activePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return openFiles.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<EditorOpenFileBuffer> find(final Path path) {
|
||||||
|
final int index = indexOf(path);
|
||||||
|
if (index < 0) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(openFiles.get(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int indexOf(final Path path) {
|
||||||
|
final var normalizedPath = normalize(path);
|
||||||
|
for (int index = 0; index < openFiles.size(); index++) {
|
||||||
|
if (openFiles.get(index).path().equals(normalizedPath)) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path normalize(final Path path) {
|
||||||
|
return Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,21 +3,41 @@ package p.studio.workspaces.editor;
|
|||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.TreeCell;
|
||||||
|
import javafx.scene.control.TreeItem;
|
||||||
|
import javafx.scene.control.TreeView;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
import javafx.scene.layout.Region;
|
import javafx.scene.layout.Region;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.StackPane;
|
||||||
import p.studio.Container;
|
import p.studio.Container;
|
||||||
import p.studio.utilities.i18n.I18n;
|
import p.studio.utilities.i18n.I18n;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public final class EditorProjectNavigatorPanel extends BorderPane {
|
public final class EditorProjectNavigatorPanel extends BorderPane {
|
||||||
|
private final Button refreshButton = new Button();
|
||||||
|
private final TreeView<EditorProjectNode> treeView = new TreeView<>();
|
||||||
|
private final Label emptyState = emptyLabel();
|
||||||
|
private final StackPane content = new StackPane();
|
||||||
|
private Runnable refreshAction = () -> { };
|
||||||
|
private Consumer<EditorProjectNode> fileSelectionAction = node -> { };
|
||||||
|
|
||||||
public EditorProjectNavigatorPanel() {
|
public EditorProjectNavigatorPanel() {
|
||||||
getStyleClass().addAll("editor-workspace-panel", "editor-workspace-navigator-panel");
|
getStyleClass().addAll("editor-workspace-panel", "editor-workspace-navigator-panel");
|
||||||
setPadding(new Insets(14, 16, 14, 16));
|
setPadding(new Insets(14, 16, 14, 16));
|
||||||
setTop(buildHeader());
|
setTop(buildHeader());
|
||||||
setCenter(buildPlaceholder());
|
configureTreeView();
|
||||||
|
content.getStyleClass().add("editor-workspace-panel-content");
|
||||||
|
content.getChildren().addAll(treeView, emptyState);
|
||||||
|
setCenter(content);
|
||||||
|
showEmptyState(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private HBox buildHeader() {
|
private HBox buildHeader() {
|
||||||
@ -25,11 +45,10 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
|
|||||||
title.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_TITLE));
|
title.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_TITLE));
|
||||||
title.getStyleClass().add("editor-workspace-panel-title");
|
title.getStyleClass().add("editor-workspace-panel-title");
|
||||||
|
|
||||||
final var refreshButton = new Button();
|
|
||||||
refreshButton.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_REFRESH));
|
refreshButton.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_REFRESH));
|
||||||
refreshButton.setDisable(true);
|
|
||||||
refreshButton.setFocusTraversable(false);
|
refreshButton.setFocusTraversable(false);
|
||||||
refreshButton.getStyleClass().addAll("studio-button", "studio-button-secondary");
|
refreshButton.getStyleClass().addAll("studio-button", "studio-button-secondary");
|
||||||
|
refreshButton.setOnAction(event -> refreshAction.run());
|
||||||
|
|
||||||
final var spacer = new Region();
|
final var spacer = new Region();
|
||||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||||
@ -39,19 +58,122 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
|
|||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
|
|
||||||
private VBox buildPlaceholder() {
|
private void configureTreeView() {
|
||||||
|
treeView.getStyleClass().add("editor-workspace-tree");
|
||||||
|
treeView.setShowRoot(true);
|
||||||
|
treeView.setFocusTraversable(false);
|
||||||
|
treeView.setCellFactory(ignored -> new TreeCell<>() {
|
||||||
|
@Override
|
||||||
|
protected void updateItem(final EditorProjectNode item, final boolean empty) {
|
||||||
|
super.updateItem(item, empty);
|
||||||
|
getStyleClass().remove("editor-workspace-tree-cell-tagged");
|
||||||
|
if (empty || item == null) {
|
||||||
|
setText(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setText(item.taggedSourceRoot() ? item.displayName() + " [src]" : item.displayName());
|
||||||
|
if (item.taggedSourceRoot()) {
|
||||||
|
getStyleClass().add("editor-workspace-tree-cell-tagged");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
treeView.getSelectionModel().selectedItemProperty().addListener((ignored, previous, current) -> {
|
||||||
|
if (current == null || current.getValue() == null || current.getValue().directory()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileSelectionAction.accept(current.getValue());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRefreshAction(final Runnable refreshAction) {
|
||||||
|
this.refreshAction = Objects.requireNonNull(refreshAction, "refreshAction");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFileSelectionAction(final Consumer<EditorProjectNode> fileSelectionAction) {
|
||||||
|
this.fileSelectionAction = Objects.requireNonNull(fileSelectionAction, "fileSelectionAction");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSnapshot(final EditorProjectSnapshot snapshot) {
|
||||||
|
final var currentSelection = selectedPath();
|
||||||
|
final var expandedPaths = captureExpandedPaths(treeView.getRoot());
|
||||||
|
final var rootItem = buildTreeItem(Objects.requireNonNull(snapshot, "snapshot").rootNode(), expandedPaths, true);
|
||||||
|
treeView.setRoot(rootItem);
|
||||||
|
restoreSelection(rootItem, currentSelection);
|
||||||
|
showEmptyState(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TreeItem<EditorProjectNode> buildTreeItem(
|
||||||
|
final EditorProjectNode node,
|
||||||
|
final Set<Path> expandedPaths,
|
||||||
|
final boolean isRoot) {
|
||||||
|
final var item = new TreeItem<>(node);
|
||||||
|
item.setExpanded(isRoot || expandedPaths.contains(node.path()));
|
||||||
|
node.children().stream()
|
||||||
|
.map(child -> buildTreeItem(child, expandedPaths, false))
|
||||||
|
.forEach(item.getChildren()::add);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<Path> captureExpandedPaths(final TreeItem<EditorProjectNode> item) {
|
||||||
|
final var expandedPaths = new HashSet<Path>();
|
||||||
|
if (item == null) {
|
||||||
|
return expandedPaths;
|
||||||
|
}
|
||||||
|
collectExpandedPaths(item, expandedPaths);
|
||||||
|
return expandedPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collectExpandedPaths(final TreeItem<EditorProjectNode> item, final Set<Path> expandedPaths) {
|
||||||
|
final var value = item.getValue();
|
||||||
|
if (value != null && item.isExpanded()) {
|
||||||
|
expandedPaths.add(value.path());
|
||||||
|
}
|
||||||
|
item.getChildren().forEach(child -> collectExpandedPaths(child, expandedPaths));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<Path> selectedPath() {
|
||||||
|
return Optional.ofNullable(treeView.getSelectionModel().getSelectedItem())
|
||||||
|
.map(TreeItem::getValue)
|
||||||
|
.map(EditorProjectNode::path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void restoreSelection(final TreeItem<EditorProjectNode> rootItem, final Optional<Path> selectedPath) {
|
||||||
|
treeView.getSelectionModel().clearSelection();
|
||||||
|
if (selectedPath.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
findTreeItem(rootItem, selectedPath.orElseThrow())
|
||||||
|
.ifPresent(item -> treeView.getSelectionModel().select(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<TreeItem<EditorProjectNode>> findTreeItem(final TreeItem<EditorProjectNode> item, final Path path) {
|
||||||
|
if (item == null || item.getValue() == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (item.getValue().path().equals(path)) {
|
||||||
|
return Optional.of(item);
|
||||||
|
}
|
||||||
|
for (final var child : item.getChildren()) {
|
||||||
|
final var match = findTreeItem(child, path);
|
||||||
|
if (match.isPresent()) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showEmptyState(final boolean empty) {
|
||||||
|
treeView.setVisible(!empty);
|
||||||
|
treeView.setManaged(!empty);
|
||||||
|
emptyState.setVisible(empty);
|
||||||
|
emptyState.setManaged(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Label emptyLabel() {
|
||||||
final var placeholder = new Label();
|
final var placeholder = new Label();
|
||||||
placeholder.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_PLACEHOLDER));
|
placeholder.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_PLACEHOLDER));
|
||||||
placeholder.getStyleClass().add("editor-workspace-placeholder");
|
placeholder.getStyleClass().add("editor-workspace-placeholder");
|
||||||
placeholder.setWrapText(true);
|
placeholder.setWrapText(true);
|
||||||
|
return placeholder;
|
||||||
final var detail = new Label();
|
|
||||||
detail.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_DETAIL));
|
|
||||||
detail.getStyleClass().add("editor-workspace-placeholder-detail");
|
|
||||||
detail.setWrapText(true);
|
|
||||||
|
|
||||||
final var content = new VBox(8, placeholder, detail);
|
|
||||||
content.getStyleClass().add("editor-workspace-panel-content");
|
|
||||||
return content;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
package p.studio.workspaces.editor;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record EditorProjectNode(
|
||||||
|
Path path,
|
||||||
|
String displayName,
|
||||||
|
boolean directory,
|
||||||
|
boolean taggedSourceRoot,
|
||||||
|
List<EditorProjectNode> children) {
|
||||||
|
public EditorProjectNode {
|
||||||
|
path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
|
||||||
|
displayName = Objects.requireNonNull(displayName, "displayName");
|
||||||
|
children = List.copyOf(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package p.studio.workspaces.editor;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record EditorProjectSnapshot(
|
||||||
|
Path projectRoot,
|
||||||
|
EditorProjectNode rootNode) {
|
||||||
|
public EditorProjectSnapshot {
|
||||||
|
projectRoot = Objects.requireNonNull(projectRoot, "projectRoot").toAbsolutePath().normalize();
|
||||||
|
rootNode = Objects.requireNonNull(rootNode, "rootNode");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
package p.studio.workspaces.editor;
|
||||||
|
|
||||||
|
import p.studio.compiler.FrontendRegistryService;
|
||||||
|
import p.studio.projects.ProjectReference;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
public final class EditorProjectSnapshotService {
|
||||||
|
|
||||||
|
public EditorProjectSnapshot createSnapshot(final ProjectReference projectReference) {
|
||||||
|
final var project = Objects.requireNonNull(projectReference, "projectReference");
|
||||||
|
final var projectRoot = project.rootPath().toAbsolutePath().normalize();
|
||||||
|
final var taggedRoots = taggedSourceRoots(project);
|
||||||
|
final var rootNode = buildNode(projectRoot, project.name(), taggedRoots);
|
||||||
|
return new EditorProjectSnapshot(projectRoot, rootNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<Path> taggedSourceRoots(final ProjectReference projectReference) {
|
||||||
|
return FrontendRegistryService.getFrontendSpec(projectReference.languageId())
|
||||||
|
.stream()
|
||||||
|
.flatMap(frontendSpec -> frontendSpec.getSourceRoots().stream())
|
||||||
|
.map(projectReference.rootPath()::resolve)
|
||||||
|
.map(Path::toAbsolutePath)
|
||||||
|
.map(Path::normalize)
|
||||||
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private EditorProjectNode buildNode(
|
||||||
|
final Path path,
|
||||||
|
final String displayName,
|
||||||
|
final Set<Path> taggedRoots) {
|
||||||
|
if (!Files.isDirectory(path)) {
|
||||||
|
return new EditorProjectNode(path, displayName, false, taggedRoots.contains(path), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<EditorProjectNode> children;
|
||||||
|
try (Stream<Path> stream = Files.list(path)) {
|
||||||
|
children = stream
|
||||||
|
.sorted(nodeComparator())
|
||||||
|
.map(child -> buildNode(child, child.getFileName().toString(), taggedRoots))
|
||||||
|
.toList();
|
||||||
|
} catch (IOException ioException) {
|
||||||
|
throw new UncheckedIOException(ioException);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EditorProjectNode(path, displayName, true, taggedRoots.contains(path), children);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Comparator<Path> nodeComparator() {
|
||||||
|
return Comparator
|
||||||
|
.comparing((Path path) -> !Files.isDirectory(path))
|
||||||
|
.thenComparing(path -> path.getFileName().toString(), String.CASE_INSENSITIVE_ORDER);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,34 +9,83 @@ import p.studio.Container;
|
|||||||
import p.studio.utilities.i18n.I18n;
|
import p.studio.utilities.i18n.I18n;
|
||||||
|
|
||||||
public final class EditorStatusBar extends HBox {
|
public final class EditorStatusBar extends HBox {
|
||||||
|
private final Label breadcrumb = new Label();
|
||||||
|
private final Label position = new Label();
|
||||||
|
private final Label lineSeparator = new Label();
|
||||||
|
private final Label indentation = new Label();
|
||||||
|
private final Label language = new Label();
|
||||||
|
private final Label readOnly = new Label();
|
||||||
|
|
||||||
public EditorStatusBar() {
|
public EditorStatusBar() {
|
||||||
setAlignment(Pos.CENTER_LEFT);
|
setAlignment(Pos.CENTER_LEFT);
|
||||||
setSpacing(12);
|
setSpacing(12);
|
||||||
getStyleClass().add("editor-workspace-status-bar");
|
getStyleClass().add("editor-workspace-status-bar");
|
||||||
|
|
||||||
final var breadcrumb = new Label();
|
|
||||||
breadcrumb.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_STATUS_BREADCRUMB));
|
|
||||||
breadcrumb.getStyleClass().add("editor-workspace-status-breadcrumb");
|
breadcrumb.getStyleClass().add("editor-workspace-status-breadcrumb");
|
||||||
|
|
||||||
final var spacer = new Region();
|
final var spacer = new Region();
|
||||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||||
|
|
||||||
|
styleChip(position);
|
||||||
|
styleChip(lineSeparator);
|
||||||
|
styleChip(indentation);
|
||||||
|
styleChip(language);
|
||||||
|
styleChip(readOnly);
|
||||||
|
|
||||||
getChildren().addAll(
|
getChildren().addAll(
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
spacer,
|
spacer,
|
||||||
statusChip(I18n.CODE_EDITOR_STATUS_POSITION),
|
position,
|
||||||
statusChip(I18n.CODE_EDITOR_STATUS_LINE_SEPARATOR),
|
lineSeparator,
|
||||||
statusChip(I18n.CODE_EDITOR_STATUS_INDENTATION),
|
indentation,
|
||||||
statusChip(I18n.CODE_EDITOR_STATUS_LANGUAGE),
|
language,
|
||||||
statusChip(I18n.CODE_EDITOR_STATUS_READ_ONLY)
|
readOnly
|
||||||
);
|
);
|
||||||
|
|
||||||
|
showPlaceholder();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Label statusChip(final I18n key) {
|
public void showFile(final EditorOpenFileBuffer fileBuffer, final String breadcrumbText) {
|
||||||
final var chip = new Label();
|
setText(breadcrumb, breadcrumbText);
|
||||||
chip.textProperty().bind(Container.i18n().bind(key));
|
bindDefault(position, I18n.CODE_EDITOR_STATUS_POSITION);
|
||||||
chip.getStyleClass().add("editor-workspace-status-chip");
|
setText(lineSeparator, fileBuffer.lineSeparator());
|
||||||
return chip;
|
bindDefault(indentation, I18n.CODE_EDITOR_STATUS_INDENTATION);
|
||||||
|
setText(language, extensionText(fileBuffer.path()));
|
||||||
|
bindDefault(readOnly, I18n.CODE_EDITOR_STATUS_READ_ONLY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void showPlaceholder() {
|
||||||
|
bindText(breadcrumb, I18n.CODE_EDITOR_STATUS_BREADCRUMB);
|
||||||
|
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);
|
||||||
|
bindDefault(readOnly, I18n.CODE_EDITOR_STATUS_READ_ONLY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindDefault(final Label label, final I18n key) {
|
||||||
|
bindText(label, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindText(final Label label, final I18n key) {
|
||||||
|
label.textProperty().unbind();
|
||||||
|
label.textProperty().bind(Container.i18n().bind(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setText(final Label label, final String text) {
|
||||||
|
label.textProperty().unbind();
|
||||||
|
label.setText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void styleChip(final Label label) {
|
||||||
|
if (!label.getStyleClass().contains("editor-workspace-status-chip")) {
|
||||||
|
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 ? fileName.substring(dot) : fileName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,33 +2,126 @@ package p.studio.workspaces.editor;
|
|||||||
|
|
||||||
import javafx.geometry.Pos;
|
import javafx.geometry.Pos;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.MenuButton;
|
||||||
|
import javafx.scene.control.MenuItem;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
import javafx.scene.layout.Region;
|
import javafx.scene.layout.Region;
|
||||||
import p.studio.Container;
|
import p.studio.Container;
|
||||||
import p.studio.utilities.i18n.I18n;
|
import p.studio.utilities.i18n.I18n;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public final class EditorTabStrip extends HBox {
|
public final class EditorTabStrip extends HBox {
|
||||||
|
private static final double TAB_WIDTH_HINT = 168.0;
|
||||||
|
private static final double OVERFLOW_WIDTH_HINT = 110.0;
|
||||||
|
|
||||||
|
private final MenuButton overflowButton = new MenuButton();
|
||||||
|
private final Region spacer = new Region();
|
||||||
|
private final List<EditorOpenFileBuffer> openFiles = new ArrayList<>();
|
||||||
|
private Consumer<Path> tabSelectionAction = path -> { };
|
||||||
|
private Path activePath;
|
||||||
|
|
||||||
public EditorTabStrip() {
|
public EditorTabStrip() {
|
||||||
setAlignment(Pos.CENTER_LEFT);
|
setAlignment(Pos.CENTER_LEFT);
|
||||||
setSpacing(10);
|
setSpacing(10);
|
||||||
getStyleClass().add("editor-workspace-tab-strip");
|
getStyleClass().add("editor-workspace-tab-strip");
|
||||||
|
|
||||||
final var placeholderTab = new Label();
|
|
||||||
placeholderTab.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_TABS_PLACEHOLDER));
|
|
||||||
placeholderTab.getStyleClass().addAll("editor-workspace-tab", "editor-workspace-tab-active");
|
|
||||||
|
|
||||||
final var spacer = new Region();
|
|
||||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||||
|
|
||||||
final var overflowButton = new Button();
|
|
||||||
overflowButton.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_TABS_OVERFLOW));
|
overflowButton.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_TABS_OVERFLOW));
|
||||||
overflowButton.setDisable(true);
|
|
||||||
overflowButton.setFocusTraversable(false);
|
overflowButton.setFocusTraversable(false);
|
||||||
overflowButton.getStyleClass().addAll("studio-button", "studio-button-secondary");
|
overflowButton.getStyleClass().addAll(
|
||||||
|
"studio-button",
|
||||||
|
"studio-button-secondary",
|
||||||
|
"editor-workspace-tab-overflow");
|
||||||
|
widthProperty().addListener((ignored, previous, current) -> rebuild());
|
||||||
|
rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
getChildren().addAll(placeholderTab, spacer, overflowButton);
|
public void setTabSelectionAction(final Consumer<Path> tabSelectionAction) {
|
||||||
|
this.tabSelectionAction = Objects.requireNonNull(tabSelectionAction, "tabSelectionAction");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void showOpenFiles(final List<EditorOpenFileBuffer> files, final Path activePath) {
|
||||||
|
this.openFiles.clear();
|
||||||
|
this.openFiles.addAll(Objects.requireNonNull(files, "files"));
|
||||||
|
this.activePath = activePath == null ? null : activePath.toAbsolutePath().normalize();
|
||||||
|
rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rebuild() {
|
||||||
|
getChildren().clear();
|
||||||
|
if (openFiles.isEmpty()) {
|
||||||
|
final var placeholderButton = new Button();
|
||||||
|
placeholderButton.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_TABS_PLACEHOLDER));
|
||||||
|
placeholderButton.setDisable(true);
|
||||||
|
placeholderButton.getStyleClass().addAll("studio-button", "studio-button-secondary");
|
||||||
|
getChildren().addAll(placeholderButton, spacer, overflowButton);
|
||||||
|
overflowButton.getItems().clear();
|
||||||
|
overflowButton.setVisible(false);
|
||||||
|
overflowButton.setManaged(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int visibleCount = visibleCount();
|
||||||
|
final int activeIndex = activeIndex();
|
||||||
|
final int startIndex = Math.max(0, Math.min(activeIndex - visibleCount + 1, openFiles.size() - visibleCount));
|
||||||
|
final int endIndex = Math.min(openFiles.size(), startIndex + visibleCount);
|
||||||
|
|
||||||
|
for (int index = startIndex; index < endIndex; index++) {
|
||||||
|
final var fileBuffer = openFiles.get(index);
|
||||||
|
final var tabButton = new Button(fileBuffer.tabLabel());
|
||||||
|
tabButton.setFocusTraversable(false);
|
||||||
|
tabButton.getStyleClass().addAll("studio-button", "studio-button-secondary", "editor-workspace-tab-button");
|
||||||
|
if (fileBuffer.path().equals(activePath)) {
|
||||||
|
tabButton.getStyleClass().add("editor-workspace-tab-button-active");
|
||||||
|
}
|
||||||
|
tabButton.setOnAction(event -> tabSelectionAction.accept(fileBuffer.path()));
|
||||||
|
getChildren().add(tabButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
getChildren().add(spacer);
|
||||||
|
final boolean hasOverflow = openFiles.size() > visibleCount;
|
||||||
|
overflowButton.getItems().setAll(buildOverflowItems(startIndex, endIndex));
|
||||||
|
overflowButton.setVisible(hasOverflow);
|
||||||
|
overflowButton.setManaged(hasOverflow);
|
||||||
|
overflowButton.setDisable(!hasOverflow);
|
||||||
|
getChildren().add(overflowButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MenuItem> buildOverflowItems(final int startIndex, final int endIndex) {
|
||||||
|
final var items = new ArrayList<MenuItem>();
|
||||||
|
for (int index = 0; index < openFiles.size(); index++) {
|
||||||
|
if (index >= startIndex && index < endIndex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final var fileBuffer = openFiles.get(index);
|
||||||
|
final var item = new MenuItem(fileBuffer.tabLabel());
|
||||||
|
item.setOnAction(event -> tabSelectionAction.accept(fileBuffer.path()));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int activeIndex() {
|
||||||
|
if (activePath == null) {
|
||||||
|
return Math.max(0, openFiles.size() - 1);
|
||||||
|
}
|
||||||
|
for (int index = 0; index < openFiles.size(); index++) {
|
||||||
|
if (openFiles.get(index).path().equals(activePath)) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.max(0, openFiles.size() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int visibleCount() {
|
||||||
|
final double availableWidth = getWidth() > 0 ? getWidth() : 760;
|
||||||
|
final double usableWidth = Math.max(availableWidth - OVERFLOW_WIDTH_HINT, TAB_WIDTH_HINT);
|
||||||
|
return Math.max(1, (int) Math.floor(usableWidth / TAB_WIDTH_HINT));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package p.studio.workspaces.editor;
|
package p.studio.workspaces.editor;
|
||||||
|
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
import javafx.scene.Node;
|
import javafx.scene.Node;
|
||||||
import javafx.scene.control.SplitPane;
|
import javafx.scene.control.SplitPane;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
@ -20,6 +21,9 @@ public final class EditorWorkspace extends Workspace {
|
|||||||
private final EditorHelperPanel helperPanel = new EditorHelperPanel();
|
private final EditorHelperPanel helperPanel = new EditorHelperPanel();
|
||||||
private final EditorStatusBar statusBar = new EditorStatusBar();
|
private final EditorStatusBar statusBar = new EditorStatusBar();
|
||||||
private final EditorTabStrip tabStrip = new EditorTabStrip();
|
private final EditorTabStrip tabStrip = new EditorTabStrip();
|
||||||
|
private final EditorProjectSnapshotService snapshotService = new EditorProjectSnapshotService();
|
||||||
|
private final EditorFileBufferLoader fileBufferLoader = new EditorFileBufferLoader();
|
||||||
|
private final EditorOpenFileSession openFileSession = new EditorOpenFileSession();
|
||||||
|
|
||||||
public EditorWorkspace(final ProjectReference projectReference) {
|
public EditorWorkspace(final ProjectReference projectReference) {
|
||||||
super(projectReference);
|
super(projectReference);
|
||||||
@ -28,12 +32,16 @@ public final class EditorWorkspace extends Workspace {
|
|||||||
codeArea.setEditable(false);
|
codeArea.setEditable(false);
|
||||||
codeArea.setWrapText(false);
|
codeArea.setWrapText(false);
|
||||||
codeArea.getStyleClass().add("editor-workspace-code-area");
|
codeArea.getStyleClass().add("editor-workspace-code-area");
|
||||||
codeArea.replaceText("""
|
showEditorPlaceholder();
|
||||||
// Read-only first wave
|
navigatorPanel.setRefreshAction(this::refreshNavigator);
|
||||||
// Open-file wiring lands in the next plan.
|
navigatorPanel.setFileSelectionAction(this::openNode);
|
||||||
""");
|
tabStrip.setTabSelectionAction(path -> {
|
||||||
|
openFileSession.activate(path);
|
||||||
|
renderSession();
|
||||||
|
});
|
||||||
|
|
||||||
root.setCenter(buildLayout());
|
root.setCenter(buildLayout());
|
||||||
|
statusBar.showPlaceholder();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public WorkspaceId workspaceId() { return WorkspaceId.EDITOR; }
|
@Override public WorkspaceId workspaceId() { return WorkspaceId.EDITOR; }
|
||||||
@ -42,7 +50,7 @@ public final class EditorWorkspace extends Workspace {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void load() {
|
public void load() {
|
||||||
|
refreshNavigator();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -52,6 +60,64 @@ public final class EditorWorkspace extends Workspace {
|
|||||||
|
|
||||||
public CodeArea codeArea() { return codeArea; }
|
public CodeArea codeArea() { return codeArea; }
|
||||||
|
|
||||||
|
private void refreshNavigator() {
|
||||||
|
navigatorPanel.setSnapshot(snapshotService.createSnapshot(projectReference));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openNode(final EditorProjectNode node) {
|
||||||
|
fileBufferLoader.load(node.path())
|
||||||
|
.ifPresentOrElse(this::openFile, () -> showUnsupportedFileModal(node.path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openFile(final EditorOpenFileBuffer fileBuffer) {
|
||||||
|
openFileSession.open(fileBuffer);
|
||||||
|
renderSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderSession() {
|
||||||
|
final var activeFile = openFileSession.activeFile();
|
||||||
|
tabStrip.showOpenFiles(
|
||||||
|
openFileSession.openFiles(),
|
||||||
|
activeFile.map(EditorOpenFileBuffer::path).orElse(null));
|
||||||
|
if (activeFile.isEmpty()) {
|
||||||
|
showEditorPlaceholder();
|
||||||
|
statusBar.showPlaceholder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final var fileBuffer = activeFile.orElseThrow();
|
||||||
|
codeArea.replaceText(fileBuffer.content());
|
||||||
|
statusBar.showFile(fileBuffer, breadcrumb(fileBuffer.path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String breadcrumb(final java.nio.file.Path path) {
|
||||||
|
final var relativePath = projectReference.rootPath()
|
||||||
|
.toAbsolutePath()
|
||||||
|
.normalize()
|
||||||
|
.relativize(path.toAbsolutePath().normalize())
|
||||||
|
.toString()
|
||||||
|
.replace('\\', '/');
|
||||||
|
return projectReference.name() + " > " + relativePath.replace("/", " > ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showUnsupportedFileModal(final java.nio.file.Path path) {
|
||||||
|
final var alert = new Alert(Alert.AlertType.INFORMATION);
|
||||||
|
if (root.getScene() != null) {
|
||||||
|
alert.initOwner(root.getScene().getWindow());
|
||||||
|
}
|
||||||
|
alert.setHeaderText(null);
|
||||||
|
alert.setTitle(p.studio.Container.i18n().text(I18n.CODE_EDITOR_UNSUPPORTED_FILE_TITLE));
|
||||||
|
alert.setContentText(p.studio.Container.i18n().format(I18n.CODE_EDITOR_UNSUPPORTED_FILE_MESSAGE, path.getFileName()));
|
||||||
|
alert.showAndWait();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showEditorPlaceholder() {
|
||||||
|
codeArea.replaceText("""
|
||||||
|
// Read-only first wave
|
||||||
|
// Open a supported text file from the project navigator.
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
private VBox buildLayout() {
|
private VBox buildLayout() {
|
||||||
final var content = new SplitPane(buildLeftColumn(), buildCenterColumn());
|
final var content = new SplitPane(buildLeftColumn(), buildCenterColumn());
|
||||||
content.setDividerPositions(0.27);
|
content.setDividerPositions(0.27);
|
||||||
|
|||||||
@ -78,6 +78,8 @@ codeEditor.status.lineSeparator=LF
|
|||||||
codeEditor.status.indentation=Spaces: 4
|
codeEditor.status.indentation=Spaces: 4
|
||||||
codeEditor.status.language=Text
|
codeEditor.status.language=Text
|
||||||
codeEditor.status.readOnly=Read-only
|
codeEditor.status.readOnly=Read-only
|
||||||
|
codeEditor.unsupportedFile.title=Unsupported file
|
||||||
|
codeEditor.unsupportedFile.message=This file is not supported in this wave: {0}
|
||||||
workspace.shipper=Shipper
|
workspace.shipper=Shipper
|
||||||
workspace.shipper.logs=Logs
|
workspace.shipper.logs=Logs
|
||||||
workspace.shipper.button.run=Build
|
workspace.shipper.button.run=Build
|
||||||
|
|||||||
@ -391,6 +391,26 @@
|
|||||||
-fx-min-height: 320px;
|
-fx-min-height: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-workspace-tree {
|
||||||
|
-fx-background-color: transparent;
|
||||||
|
-fx-border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-workspace-tree .tree-cell {
|
||||||
|
-fx-background-color: transparent;
|
||||||
|
-fx-text-fill: #d6e0ea;
|
||||||
|
-fx-font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-workspace-tree .tree-cell:selected {
|
||||||
|
-fx-background-color: #24415e;
|
||||||
|
-fx-text-fill: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-workspace-tree-cell-tagged {
|
||||||
|
-fx-font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-workspace-outline-panel {
|
.editor-workspace-outline-panel {
|
||||||
-fx-min-height: 150px;
|
-fx-min-height: 150px;
|
||||||
}
|
}
|
||||||
@ -423,6 +443,20 @@
|
|||||||
-fx-background-color: #171c22;
|
-fx-background-color: #171c22;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-workspace-tab-button {
|
||||||
|
-fx-padding: 8 12 8 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-workspace-tab-button-active {
|
||||||
|
-fx-background-color: #24415e;
|
||||||
|
-fx-border-color: #4d88bc;
|
||||||
|
-fx-text-fill: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-workspace-tab-overflow {
|
||||||
|
-fx-padding: 8 12 8 12;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-workspace-code-area .content {
|
.editor-workspace-code-area .content {
|
||||||
-fx-background-color: #171c22;
|
-fx-background-color: #171c22;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
package p.studio.workspaces.editor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
final class EditorFileBufferLoaderTest {
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadReturnsReadOnlyTextBufferForUtf8TextFile() throws Exception {
|
||||||
|
final var file = tempDir.resolve("main.pbs");
|
||||||
|
Files.writeString(file, "fn main(): void\n");
|
||||||
|
|
||||||
|
final var result = new EditorFileBufferLoader().load(file);
|
||||||
|
|
||||||
|
assertTrue(result.isPresent());
|
||||||
|
assertEquals("main.pbs", result.get().tabLabel());
|
||||||
|
assertEquals("LF", result.get().lineSeparator());
|
||||||
|
assertTrue(result.get().content().contains("fn main()"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadRejectsBinaryLikeFile() throws Exception {
|
||||||
|
final var file = tempDir.resolve("sprite.bin");
|
||||||
|
Files.write(file, new byte[]{0x01, 0x00, 0x02});
|
||||||
|
|
||||||
|
final var result = new EditorFileBufferLoader().load(file);
|
||||||
|
|
||||||
|
assertTrue(result.isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package p.studio.workspaces.editor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
session.open(file);
|
||||||
|
|
||||||
|
assertEquals(1, session.openFiles().size());
|
||||||
|
assertEquals(file.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path());
|
||||||
|
}
|
||||||
|
|
||||||
|
@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");
|
||||||
|
|
||||||
|
session.open(first);
|
||||||
|
session.open(second);
|
||||||
|
|
||||||
|
assertEquals(1, session.openFiles().size());
|
||||||
|
assertEquals(first.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path());
|
||||||
|
assertEquals("b", session.activeFile().orElseThrow().content());
|
||||||
|
}
|
||||||
|
|
||||||
|
@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");
|
||||||
|
|
||||||
|
session.open(first);
|
||||||
|
session.open(second);
|
||||||
|
session.activate(first.path());
|
||||||
|
|
||||||
|
assertEquals(first.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package p.studio.workspaces.editor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import p.studio.projects.ProjectReference;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
final class EditorProjectSnapshotServiceTest {
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void snapshotIncludesHiddenFilesOrdersFoldersFirstAndTagsSourceRoots() throws Exception {
|
||||||
|
Files.createDirectories(tempDir.resolve("src"));
|
||||||
|
Files.createDirectories(tempDir.resolve("assets"));
|
||||||
|
Files.writeString(tempDir.resolve(".env"), "TOKEN=1\n");
|
||||||
|
Files.writeString(tempDir.resolve("README.md"), "# project\n");
|
||||||
|
|
||||||
|
final var snapshot = new EditorProjectSnapshotService().createSnapshot(new ProjectReference(
|
||||||
|
"Example",
|
||||||
|
"1.0.0",
|
||||||
|
"pbs",
|
||||||
|
1,
|
||||||
|
tempDir));
|
||||||
|
|
||||||
|
assertEquals("Example", snapshot.rootNode().displayName());
|
||||||
|
assertEquals("assets", snapshot.rootNode().children().get(0).displayName());
|
||||||
|
assertEquals("src", snapshot.rootNode().children().get(1).displayName());
|
||||||
|
assertEquals(".env", snapshot.rootNode().children().get(2).displayName());
|
||||||
|
assertTrue(snapshot.rootNode().children().get(1).taggedSourceRoot());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user