diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/BookmarkInfo.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/BookmarkInfo.java new file mode 100644 index 00000000..61bd8984 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/BookmarkInfo.java @@ -0,0 +1,3 @@ +package com.basic4gl.desktop.spi; + +public record BookmarkInfo(String filePath, String fileName, int lineNumber, String lineText) {} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/DebugController.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/DebugController.java new file mode 100644 index 00000000..ff666e15 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/DebugController.java @@ -0,0 +1,11 @@ +package com.basic4gl.desktop.spi; + +public interface DebugController { + void actionPlayPause(); + + void actionStep(); + + void actionStepInto(); + + void actionStepOutOf(); +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/DialogService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/DialogService.java index 01849630..1b809e81 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/DialogService.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/DialogService.java @@ -7,5 +7,7 @@ public interface DialogService { // JComponent createContent(DialogContext context); // Boolean getResult(); // default boolean validate() { return true; } - public boolean showDialog(PluginContext context); + public void showDialog(String message); + + String showInputDialog(String message, String title, String initialValue); } diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java new file mode 100644 index 00000000..c2ffc194 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java @@ -0,0 +1,26 @@ +package com.basic4gl.desktop.spi; + +import java.io.File; +import java.util.List; + +public interface EditorCommandsService { + void openFileWithPreferredViewer(File file); + + public String collectAllSourceText(); + + void actionOpenFolder(); + + void selectNextBookmark(); + + void selectPreviousBookmark(); + + void toggleBookmark(); + + List listBookmarks(); + + void goToBookmark(String filePath, int lineNumber); + + void setWorkspaceDirectory(File selectedFile); + + void insertText(String text, int caretOffset); +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java index f67b5628..0eb2fdaf 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java @@ -1,12 +1,21 @@ package com.basic4gl.desktop.spi; +import com.basic4gl.desktop.spi.content.ContentService; + public interface PluginContext { // CommandRegistry commands(); // MenuRegistry menus(); // ToolWindowRegistry toolWindows(); + + EditorCommandsService commands(); + + DebugController debugger(); + DialogService dialogs(); MenuService menus(); + + ContentService content(); // ProjectService projects(); // EditorService editors(); FileOpener files(); @@ -15,6 +24,8 @@ public interface PluginContext { String currentDirectory(); + EditorPlugin currentEditor(); + String getLibraryPath(); SourceFileService[] fileServices(); diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ClasspathContentSource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ClasspathContentSource.java new file mode 100644 index 00000000..1974b717 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ClasspathContentSource.java @@ -0,0 +1,59 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +public final class ClasspathContentSource implements ContentSource { + + private final ClassLoader classLoader; + private final String rootPrefix; + private final List resources; + + public ClasspathContentSource(ClassLoader classLoader, String rootPrefix, String indexResourcePath) + throws IOException { + this.classLoader = Objects.requireNonNull(classLoader, "classLoader"); + this.rootPrefix = normalizePrefix(rootPrefix); + String indexPath = ContentPaths.normalize(indexResourcePath); + try (InputStream input = classLoader.getResourceAsStream(indexPath)) { + if (input == null) { + throw new FileNotFoundException("Classpath content index not found: " + indexPath); + } + this.resources = new String(input.readAllBytes(), StandardCharsets.UTF_8) + .lines() + .map(String::trim) + .filter(line -> !line.isBlank()) + .filter(line -> !line.startsWith("#")) + .map(ContentPaths::normalize) + .map(ContentResource::new) + .sorted((left, right) -> left.path().compareToIgnoreCase(right.path())) + .toList(); + } + } + + @Override + public InputStream open(String normalizedPath) throws IOException { + String safePath = ContentPaths.normalize(normalizedPath); + InputStream input = classLoader.getResourceAsStream(rootPrefix + safePath); + if (input == null) { + throw new FileNotFoundException("Classpath content resource not found: " + safePath); + } + return input; + } + + @Override + public Collection resources() { + return resources; + } + + private static String normalizePrefix(String rootPrefix) { + if (rootPrefix == null || rootPrefix.isBlank()) { + return ""; + } + return ContentPaths.normalize(rootPrefix) + "/"; + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Content.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Content.java new file mode 100644 index 00000000..c2eefe3d --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Content.java @@ -0,0 +1,35 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.File; + +@Deprecated +public class Content { + private String name; + private ContentMetadata metadata; + + private File file; + private boolean readonly; + + public Content(String name, ContentMetadata metadata, File file, boolean readonly) { + this.name = name; + this.metadata = metadata != null ? metadata : new ContentMetadata(); + this.file = file; + this.readonly = readonly; + } + + public String getName() { + return name; + } + + public ContentMetadata getMetadata() { + return metadata; + } + + public File getFile() { + return file; + } + + public boolean isReadonly() { + return readonly; + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentDocument.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentDocument.java new file mode 100644 index 00000000..96cdeb73 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentDocument.java @@ -0,0 +1,12 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.Objects; + +public record ContentDocument(String mediaType, ContentSource source, String entryPath) { + + public ContentDocument { + mediaType = ContentValidation.requireNonBlank(mediaType, "mediaType"); + source = Objects.requireNonNull(source, "source"); + entryPath = ContentValidation.requireNonBlank(entryPath, "entryPath"); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentMetadata.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentMetadata.java new file mode 100644 index 00000000..f66b744a --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentMetadata.java @@ -0,0 +1,62 @@ +package com.basic4gl.desktop.spi.content; + +@Deprecated +public class ContentMetadata { + private String category; + private String description; + private String[] tags; + private String author; + private String version; + + public ContentMetadata() { + tags = new String[0]; + } + + public ContentMetadata(String category, String description, String[] tags, String author, String version) { + this.category = category; + this.description = description; + this.tags = tags; + this.author = author; + this.version = version; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String[] getTags() { + return tags; + } + + public void setTags(String[] tags) { + this.tags = tags; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentPaths.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentPaths.java new file mode 100644 index 00000000..83a78687 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentPaths.java @@ -0,0 +1,63 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Locale; + +public final class ContentPaths { + + private ContentPaths() {} + + public static String normalize(String path) { + rejectUnsafePath(path); + String normalizedSeparators = path.replace('\\', '/'); + Deque segments = new ArrayDeque<>(); + for (String segment : normalizedSeparators.split("/+")) { + if (segment.isEmpty() || ".".equals(segment)) { + continue; + } + if ("..".equals(segment)) { + if (segments.isEmpty()) { + throw new IllegalArgumentException("Path escapes the content root: " + path); + } + segments.removeLast(); + continue; + } + segments.addLast(segment); + } + if (segments.isEmpty()) { + throw new IllegalArgumentException("Path must not resolve to the content root"); + } + return String.join("/", segments); + } + + public static String resolve(String currentDocumentPath, String relativeTarget) { + String currentPath = normalize(currentDocumentPath); + rejectUnsafePath(relativeTarget); + String parent = ""; + int lastSlash = currentPath.lastIndexOf('/'); + if (lastSlash >= 0) { + parent = currentPath.substring(0, lastSlash); + } + String combined = parent.isBlank() ? relativeTarget : parent + "/" + relativeTarget; + return normalize(combined); + } + + private static void rejectUnsafePath(String path) { + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("Path must not be null or blank"); + } + String trimmed = path.trim(); + String slashPath = trimmed.replace('\\', '/'); + if (slashPath.startsWith("/") || slashPath.startsWith("//")) { + throw new IllegalArgumentException("Absolute paths are not allowed: " + path); + } + if (slashPath.matches("^[A-Za-z]:($|/.*)")) { + throw new IllegalArgumentException("Windows absolute paths are not allowed: " + path); + } + String lower = slashPath.toLowerCase(Locale.ROOT); + if (lower.matches("^[a-z][a-z0-9+.-]*:.*")) { + throw new IllegalArgumentException("URI schemes are not allowed: " + path); + } + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentRegistration.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentRegistration.java new file mode 100644 index 00000000..a1073a6e --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentRegistration.java @@ -0,0 +1,7 @@ +package com.basic4gl.desktop.spi.content; + +public interface ContentRegistration extends AutoCloseable { + + @Override + void close(); +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentResource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentResource.java new file mode 100644 index 00000000..a11d10c0 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentResource.java @@ -0,0 +1,8 @@ +package com.basic4gl.desktop.spi.content; + +public record ContentResource(String path) { + + public ContentResource { + path = ContentValidation.requireNonBlank(path, "path"); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentService.java new file mode 100644 index 00000000..b741abe8 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentService.java @@ -0,0 +1,17 @@ +package com.basic4gl.desktop.spi.content; + +public interface ContentService { + ContentRegistration registerDocumentProvider(DocumentProvider provider); + + ContentRegistration registerTemplateProvider(TemplateProvider provider); + + @Deprecated + default void registerTemplate(Template template) { + registerTemplateProvider(new LegacyTemplateProvider(template)); + } + + @Deprecated + default void registerDocument(Content content) { + registerDocumentProvider(new LegacyContentDocumentProvider(content)); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentSource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentSource.java new file mode 100644 index 00000000..35e3c3b7 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentSource.java @@ -0,0 +1,18 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collection; + +public interface ContentSource { + + /** + * Opens a normalized path relative to this source's root. + */ + InputStream open(String normalizedPath) throws IOException; + + /** + * Returns all files belonging to this content bundle. + */ + Collection resources() throws IOException; +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentValidation.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentValidation.java new file mode 100644 index 00000000..3521791e --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentValidation.java @@ -0,0 +1,34 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.List; +import java.util.Set; + +final class ContentValidation { + + private ContentValidation() {} + + static String requireNonBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be null or blank"); + } + return value; + } + + static String optionalString(String value) { + return value == null ? "" : value; + } + + static List copyList(List values) { + if (values == null) { + return List.of(); + } + return List.copyOf(values); + } + + static Set copySet(Set values) { + if (values == null) { + return Set.of(); + } + return Set.copyOf(values); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryContentSource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryContentSource.java new file mode 100644 index 00000000..c40e3455 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryContentSource.java @@ -0,0 +1,48 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Objects; + +public final class DirectoryContentSource implements ContentSource { + + private final Path root; + + public DirectoryContentSource(Path root) { + this.root = Objects.requireNonNull(root, "root").toAbsolutePath().normalize(); + if (!Files.isDirectory(this.root)) { + throw new IllegalArgumentException("Content root is not a directory: " + root); + } + } + + @Override + public InputStream open(String normalizedPath) throws IOException { + String safePath = ContentPaths.normalize(normalizedPath); + Path target = root.resolve(safePath.replace("/", root.getFileSystem().getSeparator())) + .normalize(); + Path realRoot = root.toRealPath(); + Path realTarget = target.toRealPath(); + if (!realTarget.startsWith(realRoot) || !Files.isRegularFile(realTarget)) { + throw new FileNotFoundException("Content resource not found: " + normalizedPath); + } + return Files.newInputStream(realTarget); + } + + @Override + public Collection resources() throws IOException { + Path realRoot = root.toRealPath(); + try (var paths = Files.walk(realRoot)) { + return paths.filter(Files::isRegularFile) + .map(realRoot::relativize) + .map(path -> path.toString().replace('\\', '/')) + .map(ContentPaths::normalize) + .map(ContentResource::new) + .sorted((left, right) -> left.path().compareToIgnoreCase(right.path())) + .toList(); + } + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryDocumentProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryDocumentProvider.java new file mode 100644 index 00000000..4e32d2cd --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryDocumentProvider.java @@ -0,0 +1,138 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public class DirectoryDocumentProvider implements DocumentProvider { + + private final String id; + private final String version; + private final ContentSource source; + private final List rootCategory; + private final List index; + + public DirectoryDocumentProvider(String id, String version, Path root, List rootCategory) + throws IOException { + this(id, version, new DirectoryContentSource(root), rootCategory); + } + + public DirectoryDocumentProvider(String id, String version, ContentSource source, List rootCategory) + throws IOException { + this.id = ContentValidation.requireNonBlank(id, "id"); + this.version = ContentValidation.requireNonBlank(version, "version"); + this.source = source; + this.rootCategory = ContentValidation.copyList(rootCategory); + this.index = buildIndex(); + } + + @Override + public String id() { + return id; + } + + @Override + public String version() { + return version; + } + + @Override + public List getIndex() { + return index; + } + + @Override + public ContentDocument openDocument(String documentId) throws IOException { + for (DocumentDescriptor descriptor : index) { + if (descriptor.id().equals(documentId)) { + return new ContentDocument(mediaType(descriptor.id()), source, descriptor.id()); + } + } + throw new IOException("Unknown document: " + documentId); + } + + protected List buildIndex() throws IOException { + List descriptors = new ArrayList<>(); + int sortOrder = 0; + for (ContentResource resource : source.resources()) { + String path = resource.path(); + if (!isMarkdown(path) || hidden(path)) { + continue; + } + descriptors.add(new DocumentDescriptor( + path, + title(path), + "", + category(path), + tags(path), + "index.md".equalsIgnoreCase(path) ? -1000 : sortOrder++, + List.of())); + } + return List.copyOf(descriptors); + } + + private String title(String path) throws IOException { + try (var input = source.open(path)) { + String text = new String(input.readAllBytes(), StandardCharsets.UTF_8); + for (String line : text.split("\\R")) { + if (line.startsWith("# ")) { + return line.substring(2).trim(); + } + } + } + String filename = path.substring(path.lastIndexOf('/') + 1); + return splitTitle(filename.replaceFirst("\\.[^.]+$", "")); + } + + private List category(String path) { + List category = new ArrayList<>(rootCategory); + if (!"index.md".equalsIgnoreCase(path)) { + category.add("Reference"); + } + return List.copyOf(category); + } + + private Set tags(String path) { + if ("index.md".equalsIgnoreCase(path)) { + return Set.of("learn", "getting-started"); + } + return Set.of("reference", "guide"); + } + + private static boolean isMarkdown(String path) { + String lower = path.toLowerCase(Locale.ROOT); + return lower.endsWith(".md") || lower.endsWith(".markdown"); + } + + private static String mediaType(String path) { + return isMarkdown(path) ? "text/markdown" : "text/plain"; + } + + static boolean hidden(String path) { + for (String segment : path.split("/")) { + if (segment.startsWith(".")) { + return true; + } + } + return false; + } + + static String splitTitle(String text) { + String spaced = text.replace('-', ' ').replace('_', ' ').replaceAll("(?<=[a-z])(?=[A-Z])", " "); + StringBuilder result = new StringBuilder(); + for (String word : spaced.split("\\s+")) { + if (word.isBlank()) { + continue; + } + if (!result.isEmpty()) { + result.append(' '); + } + result.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); + } + return result.toString(); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryTemplateProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryTemplateProvider.java new file mode 100644 index 00000000..34c2b459 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryTemplateProvider.java @@ -0,0 +1,121 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public class DirectoryTemplateProvider implements TemplateProvider { + + private static final Set SHARED_DIRECTORIES = Set.of("include", "data", "files", "sounds", "textures"); + + private final String id; + private final String version; + private final ContentSource source; + private final List rootCategory; + private final List index; + + public DirectoryTemplateProvider(String id, String version, Path root, List rootCategory) + throws IOException { + this(id, version, new DirectoryContentSource(root), rootCategory); + } + + public DirectoryTemplateProvider(String id, String version, ContentSource source, List rootCategory) + throws IOException { + this.id = ContentValidation.requireNonBlank(id, "id"); + this.version = ContentValidation.requireNonBlank(version, "version"); + this.source = source; + this.rootCategory = ContentValidation.copyList(rootCategory); + this.index = buildIndex(); + } + + @Override + public String id() { + return id; + } + + @Override + public String version() { + return version; + } + + @Override + public Collection getIndex() { + return index; + } + + @Override + public void instantiate(String templateId, TemplateCreationRequest request) throws IOException { + TemplateDescriptor descriptor = find(templateId); + Files.createDirectories(request.destination()); + copyResource(descriptor.entryPoint(), request.destination()); + for (ContentResource resource : source.resources()) { + if (DirectoryDocumentProvider.hidden(resource.path())) { + continue; + } + if (isSharedResource(resource.path())) { + copyResource(resource.path(), request.destination()); + } + } + } + + protected List buildIndex() throws IOException { + List descriptors = new ArrayList<>(); + int sortOrder = 0; + for (ContentResource resource : source.resources()) { + String path = resource.path(); + if (DirectoryDocumentProvider.hidden(path) + || path.contains("/") + || !path.toLowerCase(Locale.ROOT).endsWith(".gb")) { + continue; + } + descriptors.add(new TemplateDescriptor( + path, + DirectoryDocumentProvider.splitTitle(path.replaceFirst("\\.[^.]+$", "")), + "", + rootCategory.isEmpty() ? List.of("Samples") : rootCategory, + Set.of("sample"), + sortOrder++, + path, + List.of())); + } + return List.copyOf(descriptors); + } + + private TemplateDescriptor find(String templateId) throws IOException { + for (TemplateDescriptor descriptor : index) { + if (descriptor.id().equals(templateId)) { + return descriptor; + } + } + throw new IOException("Unknown template: " + templateId); + } + + private void copyResource(String path, Path destination) throws IOException { + String safePath = ContentPaths.normalize(path); + Path target = destination + .resolve(safePath.replace("/", destination.getFileSystem().getSeparator())) + .normalize(); + if (!target.startsWith(destination)) { + throw new IOException("Template resource escapes destination: " + path); + } + Files.createDirectories(target.getParent()); + try (InputStream input = source.open(safePath)) { + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private boolean isSharedResource(String path) { + int slash = path.indexOf('/'); + if (slash <= 0) { + return false; + } + return SHARED_DIRECTORIES.contains(path.substring(0, slash).toLowerCase(Locale.ROOT)); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentDescriptor.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentDescriptor.java new file mode 100644 index 00000000..bc5cef6b --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentDescriptor.java @@ -0,0 +1,24 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.List; +import java.util.Set; + +public record DocumentDescriptor( + String id, + String title, + String description, + List categoryPath, + Set tags, + int sortOrder, + List relatedTemplateIds) + implements IndexedContent { + + public DocumentDescriptor { + id = ContentValidation.requireNonBlank(id, "id"); + title = ContentValidation.requireNonBlank(title, "title"); + description = ContentValidation.optionalString(description); + categoryPath = ContentValidation.copyList(categoryPath); + tags = ContentValidation.copySet(tags); + relatedTemplateIds = ContentValidation.copyList(relatedTemplateIds); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentProvider.java new file mode 100644 index 00000000..9d3d7910 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentProvider.java @@ -0,0 +1,21 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.util.Collection; + +public interface DocumentProvider { + + /** + * Stable provider ID within the owning plugin. + */ + String id(); + + /** + * Used for cache invalidation. Prefer a plugin or content version. + */ + String version(); + + Collection getIndex(); + + ContentDocument openDocument(String documentId) throws IOException; +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewer.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewer.java new file mode 100644 index 00000000..36f98e29 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewer.java @@ -0,0 +1,52 @@ +package com.basic4gl.desktop.spi.content; + +import com.basic4gl.desktop.spi.PluginContext; +import java.nio.file.Path; +import javax.swing.JComponent; + +/** + * Main interface for file viewers + * + * Implementations should provide a UI component that displays a specific file type. + * Viewers must handle their own resource cleanup. + */ +public interface FileViewer { + + /** + * Load and display a file + * @param path Path to the file to view + * @throws FileViewerException if file cannot be loaded or displayed + */ + void loadFile(PluginContext context, Path path) throws FileViewerException; + + /** + * Get the Swing component that displays the file + * @return JComponent to display in UI + */ + JComponent getComponent(); + + /** + * Check if this viewer can handle the given file + * @param filename Filename to check + * @param mimeType Optional MIME type (may be null) + * @return true if this viewer can display this file + */ + boolean canHandle(String filename, String mimeType); + + /** + * Get user-friendly name for this viewer + * @return Viewer name (e.g. "Image Viewer", "Audio Player") + */ + String getName(); + + /** + * Get version of this viewer (for compatibility checking) + * @return Version string + */ + String getVersion(); + + /** + * Clean up resources used by this viewer + */ + void dispose(); +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerException.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerException.java new file mode 100644 index 00000000..c043040c --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerException.java @@ -0,0 +1,14 @@ +package com.basic4gl.desktop.spi.content; + +/** + * Exception thrown by file viewer operations + */ +public class FileViewerException extends Exception { + public FileViewerException(String message) { + super(message); + } + + public FileViewerException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerMetadata.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerMetadata.java new file mode 100644 index 00000000..a241fb4e --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerMetadata.java @@ -0,0 +1,83 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Metadata about a file viewer + * + * Contains information for discovery and compatibility checking + */ +public class FileViewerMetadata { + + private final String name; + private final String version; + private final Set supportedExtensions; + private final Set supportedMimeTypes; + private final String description; + + public FileViewerMetadata( + String name, String version, String description, String[] extensions, String[] mimeTypes) { + this.name = name; + this.version = version; + this.description = description; + this.supportedExtensions = Collections.unmodifiableSet( + extensions != null ? new HashSet<>(Arrays.asList(extensions)) : new HashSet<>()); + this.supportedMimeTypes = Collections.unmodifiableSet( + mimeTypes != null ? new HashSet<>(Arrays.asList(mimeTypes)) : new HashSet<>()); + } + + public String getName() { + return name; + } + + public String getVersion() { + return version; + } + + public String getDescription() { + return description; + } + + public Set getSupportedExtensions() { + return supportedExtensions; + } + + public Set getSupportedMimeTypes() { + return supportedMimeTypes; + } + + /** + * Check if this viewer supports the given extension + */ + public boolean supportsExtension(String extension) { + if (extension == null) { + return false; + } + String normalized = extension.toLowerCase(); + if (!normalized.startsWith(".")) { + normalized = "." + normalized; + } + return supportedExtensions.contains(normalized); + } + + /** + * Check if this viewer supports the given MIME type + */ + public boolean supportsMimeType(String mimeType) { + if (mimeType == null) { + return false; + } + return supportedMimeTypes.contains(mimeType.toLowerCase()); + } + + @Override + public String toString() { + return "FileViewerMetadata{" + "name='" + + name + '\'' + ", version='" + + version + '\'' + ", extensions=" + + supportedExtensions + '}'; + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerProvider.java new file mode 100644 index 00000000..e740a738 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerProvider.java @@ -0,0 +1,22 @@ +package com.basic4gl.desktop.spi.content; + +/** + * Provider interface for creating FileViewer instances + * + * Similar to the Basic4GLPluginProvider pattern, this allows discovery via ServiceLoader. + * Implement this interface and register in META-INF/services to make viewers discoverable. + */ +public interface FileViewerProvider { + + /** + * Create a new FileViewer instance + * @return New FileViewer instance + */ + FileViewer createViewer(); + + /** + * Get metadata about this viewer provider + * @return Viewer metadata (name, version, supported file types) + */ + com.basic4gl.desktop.spi.content.FileViewerMetadata getMetadata(); +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/IndexedContent.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/IndexedContent.java new file mode 100644 index 00000000..28b5c9cc --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/IndexedContent.java @@ -0,0 +1,25 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.List; +import java.util.Set; + +public interface IndexedContent { + + /** + * Stable and unique within the provider. + */ + String id(); + + String title(); + + String description(); + + /** + * Logical UI hierarchy, not necessarily filesystem directories. + */ + List categoryPath(); + + Set tags(); + + int sortOrder(); +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/JarContentSource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/JarContentSource.java new file mode 100644 index 00000000..a9d7d3a7 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/JarContentSource.java @@ -0,0 +1,14 @@ +package com.basic4gl.desktop.spi.content; + +import java.nio.file.Path; + +public final class JarContentSource extends ZipContentSource { + + public JarContentSource(Path jarPath) { + super(jarPath); + } + + public JarContentSource(Path jarPath, String rootPrefix) { + super(jarPath, rootPrefix); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyContentDocumentProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyContentDocumentProvider.java new file mode 100644 index 00000000..1a9d1899 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyContentDocumentProvider.java @@ -0,0 +1,59 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +@Deprecated +public final class LegacyContentDocumentProvider implements DocumentProvider { + + private final Content content; + private final DocumentDescriptor descriptor; + + public LegacyContentDocumentProvider(Content content) { + this.content = content; + this.descriptor = new DocumentDescriptor( + content.getFile().getName(), + content.getName(), + content.getMetadata().getDescription(), + List.of( + content.getMetadata().getCategory() == null + ? "Legacy" + : content.getMetadata().getCategory()), + Set.of( + content.getMetadata().getTags() == null + ? new String[0] + : content.getMetadata().getTags()), + 0, + List.of()); + } + + @Override + public String id() { + return "legacy-document-" + descriptor.id(); + } + + @Override + public String version() { + return content.getMetadata().getVersion() == null + ? "legacy" + : content.getMetadata().getVersion(); + } + + @Override + public Collection getIndex() { + return List.of(descriptor); + } + + @Override + public ContentDocument openDocument(String documentId) throws IOException { + if (!descriptor.id().equals(documentId)) { + throw new IOException("Unknown legacy document: " + documentId); + } + return new ContentDocument( + "text/plain", + new DirectoryContentSource(content.getFile().getParentFile().toPath()), + content.getFile().getName()); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyTemplateProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyTemplateProvider.java new file mode 100644 index 00000000..b56fac60 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyTemplateProvider.java @@ -0,0 +1,75 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +@Deprecated +public final class LegacyTemplateProvider implements TemplateProvider { + + private final Template template; + private final TemplateDescriptor descriptor; + + public LegacyTemplateProvider(Template template) { + this.template = template; + ContentMetadata metadata = template.getMetadata(); + Content[] content = template.getContent() == null ? new Content[0] : template.getContent(); + String entryPoint = content.length == 0 || content[0].getFile() == null + ? "" + : content[0].getFile().getName(); + this.descriptor = new TemplateDescriptor( + template.getName(), + template.getName(), + metadata.getDescription(), + List.of(metadata.getCategory() == null ? "Legacy" : metadata.getCategory()), + Set.of(metadata.getTags() == null ? new String[0] : metadata.getTags()), + 0, + entryPoint, + List.of()); + } + + @Override + public String id() { + return "legacy-template-" + descriptor.id(); + } + + @Override + public String version() { + return template.getMetadata().getVersion() == null + ? "legacy" + : template.getMetadata().getVersion(); + } + + @Override + public Collection getIndex() { + return List.of(descriptor); + } + + @Override + public void instantiate(String templateId, TemplateCreationRequest request) throws IOException { + if (!descriptor.id().equals(templateId)) { + throw new IOException("Unknown legacy template: " + templateId); + } + Files.createDirectories(request.destination()); + Content[] content = template.getContent() == null ? new Content[0] : template.getContent(); + for (Content item : content) { + if (item.getFile() == null || !item.getFile().isFile()) { + continue; + } + Path target = + request.destination().resolve(item.getFile().getName()).normalize(); + if (!target.startsWith(request.destination())) { + throw new IOException("Legacy template resource escapes destination: " + + item.getFile().getName()); + } + try (InputStream input = Files.newInputStream(item.getFile().toPath())) { + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ManifestDocumentProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ManifestDocumentProvider.java new file mode 100644 index 00000000..d62a9b7d --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ManifestDocumentProvider.java @@ -0,0 +1,57 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; + +public final class ManifestDocumentProvider implements DocumentProvider { + + private final String id; + private final String version; + private final ContentSource source; + private final List descriptors; + + public ManifestDocumentProvider( + String id, String version, ContentSource source, Collection descriptors) { + this.id = ContentValidation.requireNonBlank(id, "id"); + this.version = ContentValidation.requireNonBlank(version, "version"); + this.source = source; + this.descriptors = List.copyOf(descriptors == null ? List.of() : descriptors); + } + + @Override + public String id() { + return id; + } + + @Override + public String version() { + return version; + } + + @Override + public Collection getIndex() { + return descriptors; + } + + @Override + public ContentDocument openDocument(String documentId) throws IOException { + for (DocumentDescriptor descriptor : descriptors) { + if (descriptor.id().equals(documentId)) { + return new ContentDocument(mediaType(descriptor.id()), source, descriptor.id()); + } + } + throw new IOException("Unknown document: " + documentId); + } + + private static String mediaType(String path) { + String lower = path.toLowerCase(); + if (lower.endsWith(".html") || lower.endsWith(".htm")) { + return "text/html"; + } + if (lower.endsWith(".txt")) { + return "text/plain"; + } + return "text/markdown"; + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/MapContentSource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/MapContentSource.java new file mode 100644 index 00000000..c63a995f --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/MapContentSource.java @@ -0,0 +1,62 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +public final class MapContentSource implements ContentSource { + + private final Map> resources; + + public MapContentSource(Map> resources) { + Objects.requireNonNull(resources, "resources"); + Map> copied = new LinkedHashMap<>(); + for (Map.Entry> entry : resources.entrySet()) { + String path = ContentPaths.normalize(entry.getKey()); + Supplier supplier = Objects.requireNonNull(entry.getValue(), "resource supplier"); + if (copied.put(path, supplier::get) != null) { + throw new IllegalArgumentException("Duplicate content resource path: " + path); + } + } + this.resources = Map.copyOf(copied); + } + + public static MapContentSource fromBytes(Map resources) { + Objects.requireNonNull(resources, "resources"); + Map> suppliers = new LinkedHashMap<>(); + for (Map.Entry entry : resources.entrySet()) { + byte[] bytes = + Objects.requireNonNull(entry.getValue(), "resource bytes").clone(); + suppliers.put(entry.getKey(), () -> new ByteArrayInputStream(bytes)); + } + return new MapContentSource(suppliers); + } + + @Override + public InputStream open(String normalizedPath) throws IOException { + String safePath = ContentPaths.normalize(normalizedPath); + Supplier supplier = resources.get(safePath); + if (supplier == null) { + throw new FileNotFoundException("Content resource not found: " + normalizedPath); + } + InputStream input = supplier.get(); + if (input == null) { + throw new IOException("Content resource supplier returned null: " + safePath); + } + return input; + } + + @Override + public Collection resources() { + return resources.keySet().stream() + .sorted(String::compareToIgnoreCase) + .map(ContentResource::new) + .toList(); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Template.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Template.java new file mode 100644 index 00000000..1b369edd --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Template.java @@ -0,0 +1,43 @@ +package com.basic4gl.desktop.spi.content; + +@Deprecated +public class Template { + + private String name; + private ContentMetadata metadata; + private Content[] content; + + public Template(String name, ContentMetadata metadata, Content[] content) { + this.name = name; + this.metadata = metadata != null ? metadata : new ContentMetadata(); + this.content = content; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public ContentMetadata getMetadata() { + return metadata; + } + + public void setMetadata(ContentMetadata metadata) { + this.metadata = metadata; + } + + public Content[] getContent() { + return content; + } + + public void setContent(Content[] content) { + this.content = content; + } + + public String getCategory() { + return metadata.getCategory(); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateCreationRequest.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateCreationRequest.java new file mode 100644 index 00000000..fa06470c --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateCreationRequest.java @@ -0,0 +1,14 @@ +package com.basic4gl.desktop.spi.content; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; + +public record TemplateCreationRequest(Path destination, String projectName, Map variables) { + + public TemplateCreationRequest { + destination = Objects.requireNonNull(destination, "destination"); + projectName = ContentValidation.optionalString(projectName); + variables = variables == null ? Map.of() : Map.copyOf(variables); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateDescriptor.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateDescriptor.java new file mode 100644 index 00000000..6b4016db --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateDescriptor.java @@ -0,0 +1,26 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.List; +import java.util.Set; + +public record TemplateDescriptor( + String id, + String title, + String description, + List categoryPath, + Set tags, + int sortOrder, + String entryPoint, + List relatedDocumentIds) + implements IndexedContent { + + public TemplateDescriptor { + id = ContentValidation.requireNonBlank(id, "id"); + title = ContentValidation.requireNonBlank(title, "title"); + description = ContentValidation.optionalString(description); + categoryPath = ContentValidation.copyList(categoryPath); + tags = ContentValidation.copySet(tags); + entryPoint = ContentValidation.optionalString(entryPoint); + relatedDocumentIds = ContentValidation.copyList(relatedDocumentIds); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateProvider.java new file mode 100644 index 00000000..2e709ccd --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateProvider.java @@ -0,0 +1,21 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.util.Collection; + +public interface TemplateProvider { + + /** + * Stable provider ID within the owning plugin. + */ + String id(); + + /** + * Used for cache invalidation. Prefer a plugin or content version. + */ + String version(); + + Collection getIndex(); + + void instantiate(String templateId, TemplateCreationRequest request) throws IOException; +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ZipContentSource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ZipContentSource.java new file mode 100644 index 00000000..886571c5 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ZipContentSource.java @@ -0,0 +1,85 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.FileNotFoundException; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Objects; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public class ZipContentSource implements ContentSource { + + private final Path zipPath; + private final String rootPrefix; + + public ZipContentSource(Path zipPath) { + this(zipPath, ""); + } + + public ZipContentSource(Path zipPath, String rootPrefix) { + this.zipPath = + Objects.requireNonNull(zipPath, "zipPath").toAbsolutePath().normalize(); + if (rootPrefix == null || rootPrefix.isBlank()) { + this.rootPrefix = ""; + } else { + this.rootPrefix = ContentPaths.normalize(rootPrefix) + "/"; + } + } + + @Override + public InputStream open(String normalizedPath) throws IOException { + String safePath = ContentPaths.normalize(normalizedPath); + ZipFile zipFile = openZipFile(); + ZipEntry entry = zipFile.getEntry(rootPrefix + safePath); + if (entry == null || entry.isDirectory()) { + zipFile.close(); + throw new FileNotFoundException("Content resource not found: " + normalizedPath); + } + InputStream input = zipFile.getInputStream(entry); + return new FilterInputStream(input) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + zipFile.close(); + } + } + }; + } + + @Override + public Collection resources() throws IOException { + try (ZipFile zipFile = openZipFile()) { + return zipFile.stream() + .filter(entry -> !entry.isDirectory()) + .map(ZipEntry::getName) + .filter(this::isUnderRoot) + .map(this::removeRoot) + .map(ContentPaths::normalize) + .map(ContentResource::new) + .sorted((left, right) -> left.path().compareToIgnoreCase(right.path())) + .toList(); + } catch (IllegalArgumentException ex) { + throw new IOException("ZIP content contains an unsafe resource path", ex); + } + } + + protected ZipFile openZipFile() throws IOException { + return new ZipFile(zipPath.toFile()); + } + + private boolean isUnderRoot(String entryName) { + return rootPrefix.isEmpty() || entryName.startsWith(rootPrefix); + } + + private String removeRoot(String entryName) { + if (rootPrefix.isEmpty()) { + return entryName; + } + return entryName.substring(rootPrefix.length()); + } +} diff --git a/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentDescriptorTest.java b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentDescriptorTest.java new file mode 100644 index 00000000..ab7ed1d0 --- /dev/null +++ b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentDescriptorTest.java @@ -0,0 +1,95 @@ +package com.basic4gl.desktop.spi.content; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class ContentDescriptorTest { + + @Test + void documentDescriptorRejectsBlankIdsAndTitles() { + assertThrows( + IllegalArgumentException.class, () -> new DocumentDescriptor("", "Title", null, null, null, 0, null)); + assertThrows( + IllegalArgumentException.class, () -> new DocumentDescriptor("doc", " ", null, null, null, 0, null)); + } + + @Test + void documentDescriptorUsesEmptyDefaultsAndDefensiveCopies() { + List categoryPath = new ArrayList<>(List.of("Graphics")); + Set tags = new HashSet<>(Set.of("learn")); + List relatedTemplateIds = new ArrayList<>(List.of("sprite-demo")); + + DocumentDescriptor descriptor = + new DocumentDescriptor("sprites", "Sprites", null, categoryPath, tags, 5, relatedTemplateIds); + + categoryPath.add("Mutated"); + tags.add("mutated"); + relatedTemplateIds.add("mutated"); + + assertEquals("", descriptor.description()); + assertEquals(List.of("Graphics"), descriptor.categoryPath()); + assertEquals(Set.of("learn"), descriptor.tags()); + assertEquals(List.of("sprite-demo"), descriptor.relatedTemplateIds()); + assertThrows( + UnsupportedOperationException.class, + () -> descriptor.categoryPath().add("Nope")); + } + + @Test + void templateDescriptorRejectsBlankIdsAndUsesEmptyDefaults() { + assertThrows( + IllegalArgumentException.class, + () -> new TemplateDescriptor(" ", "Template", null, null, null, 0, null, null)); + + TemplateDescriptor descriptor = new TemplateDescriptor("starter", "Starter", null, null, null, 0, null, null); + + assertEquals("", descriptor.description()); + assertEquals("", descriptor.entryPoint()); + assertEquals(List.of(), descriptor.categoryPath()); + assertEquals(Set.of(), descriptor.tags()); + assertEquals(List.of(), descriptor.relatedDocumentIds()); + } + + @Test + void contentDocumentRequiresMediaTypeSourceAndEntryPath() { + ContentSource source = new MapLikeTestSource(); + + assertThrows(IllegalArgumentException.class, () -> new ContentDocument("", source, "index.md")); + assertThrows(NullPointerException.class, () -> new ContentDocument("text/markdown", null, "index.md")); + assertThrows(IllegalArgumentException.class, () -> new ContentDocument("text/markdown", source, " ")); + } + + @Test + void templateCreationRequestCopiesVariablesAndDefaultsProjectName() { + Map variables = new HashMap<>(); + variables.put("name", "Sprite Demo"); + + TemplateCreationRequest request = new TemplateCreationRequest(Path.of("out"), null, variables); + variables.put("name", "Changed"); + + assertEquals("", request.projectName()); + assertEquals(Map.of("name", "Sprite Demo"), request.variables()); + assertThrows( + UnsupportedOperationException.class, () -> request.variables().put("other", "value")); + } + + private static final class MapLikeTestSource implements ContentSource { + @Override + public java.io.InputStream open(String normalizedPath) { + return new java.io.ByteArrayInputStream(new byte[0]); + } + + @Override + public java.util.Collection resources() { + return List.of(); + } + } +} diff --git a/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentHelperProviderTest.java b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentHelperProviderTest.java new file mode 100644 index 00000000..bc54a88f --- /dev/null +++ b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentHelperProviderTest.java @@ -0,0 +1,73 @@ +package com.basic4gl.desktop.spi.content; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ContentHelperProviderTest { + + @TempDir + Path tempDir; + + @Test + void directoryDocumentProviderInfersMarkdownDescriptors() throws Exception { + Files.writeString(tempDir.resolve("index.md"), "# Welcome\nStart"); + Files.writeString(tempDir.resolve("language-guide.md"), "# Language Guide\nReference"); + + DirectoryDocumentProvider provider = new DirectoryDocumentProvider("docs", "1", tempDir, List.of("Basic4GL")); + + assertEquals(2, provider.getIndex().size()); + DocumentDescriptor index = provider.getIndex().stream() + .filter(descriptor -> descriptor.id().equals("index.md")) + .findFirst() + .orElseThrow(); + assertEquals("Welcome", index.title()); + assertTrue(index.tags().contains("learn")); + + ContentDocument document = provider.openDocument("language-guide.md"); + assertEquals("text/markdown", document.mediaType()); + } + + @Test + void directoryTemplateProviderCopiesSelectedProgramAndSharedDirectoriesOnly() throws Exception { + Files.writeString(tempDir.resolve("SampleOne.gb"), "one"); + Files.writeString(tempDir.resolve("SampleTwo.gb"), "two"); + Files.createDirectories(tempDir.resolve("Data")); + Files.writeString(tempDir.resolve("Data/sprite.png"), "asset"); + Files.createDirectories(tempDir.resolve("Ignore")); + Files.writeString(tempDir.resolve("Ignore/file.txt"), "ignored"); + + DirectoryTemplateProvider provider = new DirectoryTemplateProvider("samples", "1", tempDir, List.of("Samples")); + Path destination = tempDir.resolveSibling("out"); + + provider.instantiate("SampleOne.gb", new TemplateCreationRequest(destination, "Sample One", null)); + + assertEquals("one", Files.readString(destination.resolve("SampleOne.gb"))); + assertFalse(Files.exists(destination.resolve("SampleTwo.gb"))); + assertEquals("asset", Files.readString(destination.resolve("Data/sprite.png"))); + assertFalse(Files.exists(destination.resolve("Ignore/file.txt"))); + } + + @Test + void classpathContentSourceEnumeratesFromIndex() throws Exception { + Path classes = Files.createDirectories(tempDir.resolve("classes")); + Files.createDirectories(classes.resolve("content/docs")); + Files.writeString(classes.resolve("content/docs/index.md"), "# Index"); + Files.writeString(classes.resolve("content/docs.index"), "index.md\n", StandardCharsets.UTF_8); + + try (URLClassLoader classLoader = + new URLClassLoader(new java.net.URL[] {classes.toUri().toURL()})) { + ClasspathContentSource source = + new ClasspathContentSource(classLoader, "content/docs", "content/docs.index"); + + assertEquals(List.of(new ContentResource("index.md")), source.resources()); + assertEquals("# Index", new String(source.open("index.md").readAllBytes(), StandardCharsets.UTF_8)); + } + } +} diff --git a/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentPathsTest.java b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentPathsTest.java new file mode 100644 index 00000000..a3d7b155 --- /dev/null +++ b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentPathsTest.java @@ -0,0 +1,40 @@ +package com.basic4gl.desktop.spi.content; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class ContentPathsTest { + + @Test + void normalizesSeparatorsAndDotSegments() { + assertEquals("tutorials/images/sprite.png", ContentPaths.normalize("tutorials\\./images//sprite.png")); + assertEquals("images/sprite.png", ContentPaths.normalize("tutorials/../images/sprite.png")); + } + + @Test + void resolvesRelativeTargetsAgainstCurrentDocumentParent() { + assertEquals( + "docs/images/sprite.png", ContentPaths.resolve("docs/tutorials/sprites.md", "../images/sprite.png")); + assertEquals("docs/tutorials/page2.md", ContentPaths.resolve("docs/tutorials/sprites.md", "page2.md")); + } + + @Test + void rejectsEscapesAbsolutePathsAndUriSchemes() { + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize("../../outside.png")); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize("/absolute/path")); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize("C:\\absolute\\path")); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize("file:///tmp/index.md")); + assertThrows( + IllegalArgumentException.class, () -> ContentPaths.normalize("jar:file:///tmp/docs.jar!/index.md")); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize("https://example.invalid/index.md")); + } + + @Test + void rejectsBlankAndRootOnlyPaths() { + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize(null)); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize(" ")); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize(".")); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize("a/..")); + } +} diff --git a/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentSourceTest.java b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentSourceTest.java new file mode 100644 index 00000000..1988d3e0 --- /dev/null +++ b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentSourceTest.java @@ -0,0 +1,93 @@ +package com.basic4gl.desktop.spi.content; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ContentSourceTest { + + @TempDir + Path tempDir; + + @Test + void mapContentSourceOpensAndListsNormalizedResources() throws Exception { + MapContentSource source = + MapContentSource.fromBytes(Map.of("docs\\index.md", "Hello".getBytes(StandardCharsets.UTF_8))); + + assertEquals("Hello", readString(source.open("docs/index.md"))); + assertEquals(Set.of("docs/index.md"), paths(source)); + assertThrows(IllegalArgumentException.class, () -> source.open("../outside.md")); + assertThrows(FileNotFoundException.class, () -> source.open("missing.md")); + } + + @Test + void directoryContentSourceOpensAndListsResources() throws Exception { + Path docs = Files.createDirectories(tempDir.resolve("docs/images")); + Files.writeString(tempDir.resolve("docs/index.md"), "Index"); + Files.writeString(docs.resolve("sprite.png"), "Image"); + + DirectoryContentSource source = new DirectoryContentSource(tempDir); + + assertEquals("Index", readString(source.open("docs/index.md"))); + assertEquals(Set.of("docs/index.md", "docs/images/sprite.png"), paths(source)); + assertThrows(IllegalArgumentException.class, () -> source.open("../outside.md")); + } + + @Test + void zipContentSourceOpensAndListsResourcesUnderPrefix() throws Exception { + Path zipPath = tempDir.resolve("docs.zip"); + writeZip( + zipPath, + Map.of( + "content/index.md", "Index", + "content/images/sprite.png", "Image", + "other/ignored.md", "Ignored")); + + ZipContentSource source = new ZipContentSource(zipPath, "content"); + + assertEquals("Index", readString(source.open("index.md"))); + assertEquals(Set.of("index.md", "images/sprite.png"), paths(source)); + } + + @Test + void jarContentSourceUsesZipBehavior() throws Exception { + Path jarPath = tempDir.resolve("docs.jar"); + writeZip(jarPath, Map.of("docs/index.md", "Index")); + + JarContentSource source = new JarContentSource(jarPath, "docs"); + + assertEquals("Index", readString(source.open("index.md"))); + assertEquals(Set.of("index.md"), paths(source)); + } + + private static Set paths(ContentSource source) throws Exception { + return source.resources().stream().map(ContentResource::path).collect(Collectors.toSet()); + } + + private static String readString(InputStream input) throws Exception { + try (input) { + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static void writeZip(Path zipPath, Map entries) throws Exception { + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(zipPath))) { + for (Map.Entry entry : entries.entrySet()) { + output.putNextEntry(new ZipEntry(entry.getKey())); + output.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + } +} diff --git a/app/build.gradle b/app/build.gradle index fc7a3a49..ff2a585e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,7 +3,6 @@ plugins { id("com.diffplug.spotless") version "7.0.2" } apply plugin: "java" - configurations { release.extendsFrom configurations.default } @@ -97,6 +96,20 @@ dependencies { // for FlatDesktop Java 8 backports implementation 'com.formdev:flatlaf-extras:3.5.4' + implementation 'org.commonmark:commonmark:0.29.0' + + def osName = System.getProperty("os.name").toLowerCase() + def osArch = System.getProperty("os.arch").toLowerCase() + def javafxPlatform = osName.contains("mac") + ? (osArch.contains("aarch64") || osArch.contains("arm64") ? "mac-aarch64" : "mac") + : (osName.contains("win") ? "win" : "linux") + implementation "org.openjfx:javafx-base:21.0.4:${javafxPlatform}" + implementation "org.openjfx:javafx-graphics:21.0.4:${javafxPlatform}" + implementation "org.openjfx:javafx-controls:21.0.4:${javafxPlatform}" + implementation "org.openjfx:javafx-media:21.0.4:${javafxPlatform}" + implementation "org.openjfx:javafx-swing:21.0.4:${javafxPlatform}" + implementation "org.openjfx:javafx-web:21.0.4:${javafxPlatform}" + // for IS_OS_MAC flag for OS specific behavior implementation("org.apache.commons:commons-lang3:3.14.0") @@ -105,7 +118,7 @@ dependencies { implementation ('javax.websocket:javax.websocket-api:1.0') implementation ('org.eclipse.jetty.websocket:javax-websocket-client-impl:9.4.49.v20220914') - testImplementation 'junit:junit:4.12' + testImplementation 'junit:junit:4.13.1' } spotless { @@ -119,6 +132,9 @@ spotless { formatAnnotations() trimTrailingWhitespace() endWithNewline() + + // Exclude ANTLR-generated sources from formatting checks. + targetExclude fileTree("${project.buildDir}/generated-src") } } diff --git a/app/src/META-INF/services/com.basic4gl.desktop.spi.content.FileViewerProvider b/app/src/META-INF/services/com.basic4gl.desktop.spi.content.FileViewerProvider new file mode 100644 index 00000000..4a315206 --- /dev/null +++ b/app/src/META-INF/services/com.basic4gl.desktop.spi.content.FileViewerProvider @@ -0,0 +1,5 @@ +com.basic4gl.desktop.content.DefaultImageViewerProvider +com.basic4gl.desktop.content.DefaultAudioViewerProvider +com.basic4gl.desktop.content.MarkdownViewerProvider +com.basic4gl.desktop.content.SimpleTextViewerProvider + diff --git a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java index 0a964c19..ed43ec03 100644 --- a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java @@ -8,10 +8,23 @@ import com.basic4gl.debug.protocol.callbacks.VariablesCallback; import com.basic4gl.debug.protocol.types.DisassembledInstruction; import com.basic4gl.debug.protocol.types.Variable; +import com.basic4gl.desktop.content.ContentDocumentViewer; +import com.basic4gl.desktop.content.ContentMaterializer; +import com.basic4gl.desktop.content.FileManager; +import com.basic4gl.desktop.content.TemplateInstantiator; +import com.basic4gl.desktop.content.catalog.ContentCatalog; +import com.basic4gl.desktop.content.catalog.DefaultContentService; +import com.basic4gl.desktop.content.catalog.DocumentCatalogEntry; +import com.basic4gl.desktop.content.catalog.TemplateCatalogEntry; import com.basic4gl.desktop.debugger.*; +import com.basic4gl.desktop.editor.ApMode; import com.basic4gl.desktop.editor.BasicTokenMaker; -import com.basic4gl.desktop.editor.FileEditor; +import com.basic4gl.desktop.content.FileEditor; +import com.basic4gl.desktop.editor.IEditorPresenter; import com.basic4gl.desktop.spi.*; +import com.basic4gl.desktop.spi.content.ContentDocument; +import com.basic4gl.desktop.spi.content.ContentService; +import com.basic4gl.desktop.spi.language.LanguageSupport; import com.basic4gl.desktop.util.*; import com.basic4gl.language.adapter.Basic4GLEditorPluginAdapter; import com.basic4gl.language.core.runtime.CallbackMessage; @@ -20,11 +33,12 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.nio.file.Path; import java.util.*; import javax.swing.SwingUtilities; import org.apache.commons.lang3.SystemUtils; -public class BasicEditor implements MainEditor, IApplicationHost, IFileProvider, PluginContext { +public class BasicEditor implements MainEditor, IApplicationHost, IFileProvider, PluginContext, DebugController { private static final int GLOBAL_VARIABLES_PAGE_SIZE = 128; private static final int MEMORY_VARIABLES_PAGE_SIZE = 64; @@ -76,14 +90,29 @@ public class BasicEditor implements MainEditor, IApplicationHost, IFileProvider, private String libraryPath; private final Basic4GLEditorPluginAdapter basic4gl; + private final DialogService dialogService; + private final EditorCommandsService commandsService; + private final ContentCatalog contentCatalog = new ContentCatalog(); + private final ContentService contentService; + private final ContentMaterializer contentMaterializer = + new ContentMaterializer(Path.of(System.getProperty("user.home"), ".basic4glj", "cache")); + private final TemplateInstantiator templateInstantiator = new TemplateInstantiator(); public BasicEditor( - String libraryPath, FileManager fileManager, IEditorPresenter presenter, MenuService menuService) { + String libraryPath, + FileManager fileManager, + IEditorPresenter presenter, + DialogService dialogService, + MenuService menuService, + EditorCommandsService commandsService) { this.libraryPath = libraryPath; this.fileManager = fileManager; this.presenter = presenter; + this.dialogService = dialogService; this.menuService = menuService; + this.commandsService = commandsService; this.basic4gl = new Basic4GLEditorPluginAdapter(this); + this.contentService = new DefaultContentService(contentCatalog, basic4gl.getId(), basic4gl.getName()); this.basic4gl.setOnPluginStateChanged(this::refreshSyntaxHighlighting); this.basic4gl.setOnPluginDirectoryHistoryChanged(this::syncPluginDirectorySettings); this.vmWorker = new VmWorker(this); @@ -107,21 +136,12 @@ public void refreshSyntaxHighlighting() { BasicTokenMaker.functions.clear(); BasicTokenMaker.constants.clear(); BasicTokenMaker.operators.clear(); - for (String s : basic4gl.getLanguage().getReservedWords()) { - BasicTokenMaker.reservedWords.add(s); - } - for (String s : basic4gl.getLanguage().getConstants()) { - BasicTokenMaker.constants.add(s); - } + BasicTokenMaker.reservedWords.addAll(basic4gl.getLanguage().getReservedWords()); + BasicTokenMaker.constants.addAll(basic4gl.getLanguage().getConstants()); + BasicTokenMaker.functions.addAll(basic4gl.getLanguage().getFunctions()); + BasicTokenMaker.operators.addAll(basic4gl.getLanguage().getOperators()); - for (String s : basic4gl.getLanguage().getFunctions()) { - BasicTokenMaker.functions.add(s); - } - - for (String s : basic4gl.getLanguage().getOperators()) { - BasicTokenMaker.operators.add(s); - } presenter.refreshSyntaxHighlighting(); } @@ -231,6 +251,9 @@ public int isBreakpt(String filename, int line) { @Override public boolean toggleBreakpt(String filename, int line) { + if (vmWorker == null) { + return false; + } return vmWorker.toggleBreakpoint(filename, line); } @@ -263,8 +286,10 @@ public void refreshUI() { // Compilation and execution routines public boolean loadProgramIntoCompiler() { - // TODO Get editor assigned as main file - int mainFiledIndex = 0; + int mainFiledIndex = fileManager.getRunnableFileIndex(); + if (mainFiledIndex < 0) { + return false; + } return basic4gl.getPreprocessor() .preprocess(new EditorSourceFile( @@ -318,6 +343,7 @@ public boolean compile() { // TODO Reset file directory // SetCurrentDir(mRunDirectory); + presenter.onCompileSucceeded(); return true; } @@ -1080,10 +1106,18 @@ public void stopOrCancelRunningApplication() { } @Override - public DialogService dialogs() { + public EditorCommandsService commands() { + return commandsService; + } - // TODO implement dialogs for plugins - return null; + @Override + public DebugController debugger() { + return this; + } + + @Override + public DialogService dialogs() { + return dialogService; } @Override @@ -1091,6 +1125,40 @@ public MenuService menus() { return menuService; } + @Override + public ContentService content() { + return contentService; + } + + public ContentCatalog contentCatalog() { + return contentCatalog; + } + + public void openContentDocument(DocumentCatalogEntry entry) { + try { + ContentDocument document = + entry.provider().openDocument(entry.descriptor().id()); + Path materializedRoot = contentMaterializer.materialize( + entry.globalId().pluginId(), + entry.globalId().providerId(), + entry.providerVersion(), + document.source()); + presenter.openDocumentationPreview(new ContentDocumentViewer( + entry.globalId().value(), entry.descriptor().title(), document, materializedRoot)); + } catch (IOException | RuntimeException ex) { + dialogService.showDialog("Unable to open documentation: " + ex.getMessage()); + } + } + + public void instantiateTemplate(TemplateCatalogEntry entry, Path destination, String projectName) { + try { + Optional entryPoint = templateInstantiator.instantiate(entry, destination, projectName, Map.of()); + entryPoint.ifPresent(path -> commandsService.openFileWithPreferredViewer(path.toFile())); + } catch (IOException | RuntimeException ex) { + dialogService.showDialog("Unable to create sample: " + ex.getMessage()); + } + } + @Override public FileOpener files() { return fileOpener; @@ -1114,6 +1182,11 @@ public String currentDirectory() { return fileManager.getCurrentDirectory(); } + @Override + public EditorPlugin currentEditor() { + return basic4gl; + } + @Override public String getLibraryPath() { return libraryPath; @@ -1135,6 +1208,7 @@ public String getDefaultDebuggerPort() { } public void onCloseAll() { + contentCatalog.unregisterPlugin(basic4gl.getId()); basic4gl.onCloseAll(); } @@ -1154,6 +1228,10 @@ public LanguageService getLanguageService() { return basic4gl.getLanguage(); } + public LanguageSupport getLanguageSupport() { + return basic4gl.getLanguageSupport(); + } + public List getBuilders() { return builders; } @@ -1437,7 +1515,7 @@ public void loadSettings() { syncPluginDirectorySettings(); } - public void onFileOpened(com.basic4gl.desktop.editor.FileEditor editor) { + public void onFileOpened(FileEditor editor) { if (editor == null || editor.getEditorPane() == null) { return; } @@ -1445,7 +1523,7 @@ public void onFileOpened(com.basic4gl.desktop.editor.FileEditor editor) { refreshSyntaxHighlighting(); } - public void onFileSaving(com.basic4gl.desktop.editor.FileEditor editor) { + public void onFileSaving(FileEditor editor) { if (editor == null || editor.getEditorPane() == null) { return; } diff --git a/app/src/main/java/com/basic4gl/desktop/EmptyTabPanel.java b/app/src/main/java/com/basic4gl/desktop/EmptyTabPanel.java index 31497b71..329efdfe 100644 --- a/app/src/main/java/com/basic4gl/desktop/EmptyTabPanel.java +++ b/app/src/main/java/com/basic4gl/desktop/EmptyTabPanel.java @@ -18,7 +18,9 @@ public EmptyTabPanel( IEmptyTabPanelListener listener, KeyStroke newFileKeyStroke, KeyStroke openFileKeyStroke, - List recentFiles) { + KeyStroke openFolderKeyStroke, + List recentFiles, + List recentWorkspaces) { super(); setLayout(new BorderLayout()); @@ -29,17 +31,24 @@ public EmptyTabPanel( JScrollPane scrollPane = new JScrollPane(contents); scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); - contents.add(buildGetStartedPanel(listener, newFileKeyStroke, openFileKeyStroke)); + contents.add(buildGetStartedPanel(listener, newFileKeyStroke, openFileKeyStroke, openFolderKeyStroke)); contents.add(new Box.Filler(new Dimension(16, 16), new Dimension(16, 16), new Dimension(16, 16))); contents.add(buildRecentFilesPanel(listener, recentFiles)); + contents.add(new Box.Filler(new Dimension(16, 16), new Dimension(16, 16), new Dimension(16, 16))); + + contents.add(buildRecentWorkspacesPanel(listener, recentWorkspaces)); + add(scrollPane); } private JPanel buildGetStartedPanel( - IEmptyTabPanelListener listener, KeyStroke newFileKeyStroke, KeyStroke openFileKeyStroke) { + IEmptyTabPanelListener listener, + KeyStroke newFileKeyStroke, + KeyStroke openFileKeyStroke, + KeyStroke openFolderKeyStroke) { JPanel getStartedPanel = new JPanel(); getStartedPanel.setLayout(new BoxLayout(getStartedPanel, BoxLayout.Y_AXIS)); getStartedPanel.setAlignmentX(Component.LEFT_ALIGNMENT); @@ -93,6 +102,22 @@ private JPanel buildGetStartedPanel( addShortCutLabel(wrapper, openFileKeyStroke); getStartedPanel.add(wrapper); + wrapper = new JPanel(new FlowLayout(FlowLayout.LEFT)); + wrapper.setMaximumSize(new Dimension(400, 50)); + FlatButton openFolderButton = new FlatButton(); + openFolderButton.setText("Open Folder..."); + openFolderButton.setIcon(createImageIcon(ICON_MENU_FOLDER)); + openFolderButton.setBackground(null); + openFolderButton.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12)); + openFolderButton.setPreferredSize(new Dimension(160, 30)); + openFolderButton.setAlignmentX(Component.LEFT_ALIGNMENT); + openFolderButton.setHorizontalAlignment(SwingConstants.LEFT); + openFolderButton.addActionListener((x) -> listener.onOpenFolderClick()); + + wrapper.add(openFolderButton); + addShortCutLabel(wrapper, openFolderKeyStroke); + getStartedPanel.add(wrapper); + return getStartedPanel; } @@ -136,6 +161,43 @@ private JPanel buildRecentFilesPanel(IEmptyTabPanelListener listener, List return recentFilePanel; } + private JPanel buildRecentWorkspacesPanel(IEmptyTabPanelListener listener, List recentWorkspaces) { + JPanel recentWorkspacePanel = new JPanel(); + recentWorkspacePanel.setLayout(new BoxLayout(recentWorkspacePanel, BoxLayout.Y_AXIS)); + recentWorkspacePanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + JPanel wrapper = new JPanel(new FlowLayout(FlowLayout.LEFT)); + wrapper.setMaximumSize(new Dimension(400, 50)); + wrapper.setAlignmentX(Component.LEFT_ALIGNMENT); + JLabel recentLabel = new JLabel("Recent Workspaces"); + Font font = recentLabel.getFont(); + recentLabel.setFont(new Font(font.getName(), Font.BOLD, font.getSize() + 2)); + recentLabel.setForeground(new Color(66, 66, 66)); + recentLabel.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12)); + + wrapper.add(recentLabel); + recentWorkspacePanel.add(wrapper); + + for (File folder : recentWorkspaces.stream().limit(5).toList()) { + JPanel recentItem = new JPanel(new FlowLayout(FlowLayout.LEFT)); + recentItem.setAlignmentX(Component.LEFT_ALIGNMENT); + recentItem.setMaximumSize(new Dimension(500, 30)); + recentItem.setBorder(null); + + JButton recentItemButton = new FlatButton(); + recentItemButton.setBackground(null); + recentItemButton.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12)); + recentItemButton.setText(folder.getName().isBlank() ? folder.getAbsolutePath() : folder.getName()); + recentItemButton.setToolTipText(folder.getAbsolutePath()); + recentItemButton.addActionListener((x) -> listener.onOpenWorkspaceClick(folder)); + + recentItem.add(recentItemButton); + recentWorkspacePanel.add(recentItem); + } + + return recentWorkspacePanel; + } + private void addShortCutLabel(JPanel parent, KeyStroke keyStroke) { for (String s : KeyStrokeUtil.getShortcutString(keyStroke).split(" ")) { JLabel l = new JLabel(s); @@ -159,5 +221,9 @@ public static interface IEmptyTabPanelListener { void onOpenClick(); void onOpenClick(File file); + + void onOpenFolderClick(); + + void onOpenWorkspaceClick(File folder); } } diff --git a/app/src/main/java/com/basic4gl/desktop/ExportDialog.java b/app/src/main/java/com/basic4gl/desktop/ExportDialog.java index fd7344d4..a579a43e 100644 --- a/app/src/main/java/com/basic4gl/desktop/ExportDialog.java +++ b/app/src/main/java/com/basic4gl/desktop/ExportDialog.java @@ -1,7 +1,7 @@ package com.basic4gl.desktop; import com.basic4gl.compiler.util.IAssetExportBuilder; -import com.basic4gl.desktop.editor.FileEditor; +import com.basic4gl.desktop.content.FileEditor; import com.basic4gl.desktop.spi.*; import com.basic4gl.desktop.util.EditorSourceFile; import com.formdev.flatlaf.ui.FlatTabbedPaneUI; @@ -21,8 +21,11 @@ * Created by Nate on 2/5/2015. */ public class ExportDialog implements com.basic4gl.desktop.spi.ConfigurationFormPanel.IOnConfigurationChangeListener { + private final CompilerService compiler; private final PreprocessorService preprocessor; + private final LanguageService languageService; + private final Vector fileEditors; private final JDialog dialog; @@ -51,12 +54,14 @@ public ExportDialog( Frame parent, CompilerService compiler, PreprocessorService preprocessor, + LanguageService languageService, Vector editors, String exportBaseDirectory, java.util.List contributedExportPages) { this.compiler = compiler; this.preprocessor = preprocessor; + this.languageService = languageService; fileEditors = editors; this.exportBaseDirectory = com.basic4gl.language.adapter.FileUtil.separatorsToSystem(exportBaseDirectory); @@ -530,7 +535,7 @@ private java.util.List detectAssetsFromSourceLiterals() { continue; } - for (String literal : extractStringLiterals(text)) { + for (String literal : languageService.extractStringLiterals(text)) { if (literal == null || literal.isBlank()) { continue; } @@ -555,52 +560,6 @@ private java.util.List detectAssetsFromSourceLiterals() { return new ArrayList<>(detected); } - static java.util.List extractStringLiterals(String text) { - java.util.List literals = new ArrayList<>(); - if (text == null || text.isEmpty()) { - return literals; - } - - int length = text.length(); - int index = 0; - while (index < length) { - char ch = text.charAt(index); - if (ch != '"') { - index++; - continue; - } - - // Basic4GL string literals have no escape sequences: the first '"' always - // ends the literal, and backslash is an ordinary character (see STRING_LIT - // in Basic4GL.g4). - StringBuilder literal = new StringBuilder(); - index++; - boolean terminated = false; - while (index < length) { - char current = text.charAt(index++); - - if (current == '\n' || current == '\r') { - break; - } - - if (current == '"') { - literals.add(literal.toString()); - terminated = true; - break; - } - - literal.append(current); - } - - // Unterminated string literal: discard and continue scanning. - if (!terminated) { - continue; - } - } - - return literals; - } - private void export() { try { File dest; diff --git a/app/src/main/java/com/basic4gl/desktop/FileManager.java b/app/src/main/java/com/basic4gl/desktop/FileManager.java deleted file mode 100644 index 566d7e78..00000000 --- a/app/src/main/java/com/basic4gl/desktop/FileManager.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.basic4gl.desktop; - -import com.basic4gl.desktop.editor.FileEditor; -import com.basic4gl.desktop.util.IFileManager; -import com.basic4gl.language.core.internal.Mutable; -import java.io.File; -import java.util.Vector; -import javax.swing.*; - -public class FileManager implements IFileManager { - - private final Vector fileEditors = new Vector<>(); - - private String currentDirectory; // Current working directory - - private String appDirectory; // Application directory (where the basic4glj executable is) - private String fileDirectory; // File I/O in this directory - private String runDirectory; // Basic4GL programs are run in this directory - - private final IFileManagerListener listener; - - public FileManager(IFileManagerListener listener) { - this.listener = listener; - } - - @Override - public int editorCount() { - return fileEditors.size(); - } - - @Override - public JTextArea getEditor(int index) { - return fileEditors.get(index).getEditorPane(); - } - - public String getFilename(int index) { - return fileEditors.get(index).getFilePath(); - } - - public void setCurrentDirectory(String path) { - currentDirectory = path; - if (listener != null) { - listener.onCurrentDirectoryChanged(path); - } - } - - public String getCurrentDirectory() { - return currentDirectory; - } - - @Override - public String getAppDirectory() { - return appDirectory; - } - - public int getFileTabIndex(String filename) { - File file = new File(getCurrentDirectory(), filename); - return getTabIndex(file.getAbsolutePath()); - } - - public int getTabIndex(String path) { - int i = 0; - boolean found = false; - for (; i < fileEditors.size(); i++) { - if (fileEditors.get(i).getFilePath().equals(path)) { - found = true; - break; - } - } - return found ? i : -1; - } - - public void selectPreviousBreakpoint(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).gotoNextBreakpoint(false); - } - } - - public void selectNextBreakpoint(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).gotoNextBreakpoint(true); - } - } - - public void toggleBookmark(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).toggleBookmark(); - } - } - - public void selectPreviousBookmark(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).gotoNextBookmark(false); - } - } - - public void selectNextBookmark(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).gotoNextBookmark(true); - } - } - - public void selectAll(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).getEditorPane().selectAll(); - } - } - - public void paste(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).getEditorPane().paste(); - } - } - - public void copy(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).getEditorPane().copy(); - } - } - - public void cut(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).getEditorPane().cut(); - } - } - - public void redo(int i) { - if (i > -1 && i < fileEditors.size()) { - if (fileEditors.get(i).canRedo()) { - fileEditors.get(i).redoLastAction(); - } - } - } - - public void undo(int i) { - if (i > -1 && i < fileEditors.size()) { - if (fileEditors.get(i).canUndo()) { - fileEditors.get(i).undoLastAction(); - } - } - } - - public void toggleBreakpoint(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).toggleBreakpoint(); - } - } - - public void setReadOnly(boolean readOnly) { - for (int i = 0; i < fileEditors.size(); i++) { - fileEditors.get(i).getEditorPane().setEditable(!readOnly); - } - } - - public boolean isMultifileModified(Mutable description) { - boolean result = false; - String desc = ""; - - for (int i = 0; i < fileEditors.size(); i++) { - if (fileEditors.get(i).isModified()) { - result = true; - if (!desc.isEmpty()) { - desc += ", "; - } - String filename = fileEditors.get(i).getShortFilename(); - - desc += filename; - } - } - description.set(desc); - return result; - } - - public void setAppDirectory(String appDirectory) { - this.appDirectory = appDirectory; - } - - public String getFileDirectory() { - return fileDirectory; - } - - public void setFileDirectory(String fileDirectory) { - this.fileDirectory = fileDirectory; - } - - public String getRunDirectory() { - return runDirectory; - } - - public void setRunDirectory(String runDirectory) { - this.runDirectory = runDirectory; - } - - public Vector getFileEditors() { - return fileEditors; - } -} diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 9a26c697..80325ab3 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -2,14 +2,23 @@ import static com.basic4gl.desktop.Theme.*; import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; import static com.formdev.flatlaf.FlatClientProperties.*; import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.callbacks.VariablesCallback; +import com.basic4gl.desktop.content.*; import com.basic4gl.desktop.debugger.DebugServerConstants; import com.basic4gl.desktop.debugger.DebugServerFactory; +import com.basic4gl.desktop.debugger.IDebugPresenter; import com.basic4gl.desktop.editor.*; +import com.basic4gl.desktop.language.SymbolIndexer; +import com.basic4gl.desktop.panels.*; +import com.basic4gl.desktop.spi.*; +import com.basic4gl.desktop.util.BasicDialogService; +import com.basic4gl.desktop.util.RoundedCardPanel; import com.basic4gl.desktop.language.NonRetriggeringAutoCompletion; import com.basic4gl.desktop.language.SymbolCompletionCellRenderer; import com.basic4gl.desktop.language.SymbolCompletionProvider; @@ -54,7 +63,8 @@ public class MainWindow IFileEditorActionListener, IFileManagerListener, EmptyTabPanel.IEmptyTabPanelListener, - MenuService { + MenuService, + EditorCommandsService { private final CaretListener TrackCaretPosition = new CaretListener() { @Override @@ -75,12 +85,75 @@ public void caretUpdate(CaretEvent e) { } }; + // Feature flags + // TODO remove once the redesigned left sidebar/rail is ready for release. + private static final boolean LEFT_SIDEBAR_ENABLED = false; + // Window private final JFrame frame = new JFrame(BuildInfo.APPLICATION_NAME); - private final JSplitPane mainPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); - private final JSplitPane debugPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + private final JSplitPane mainPane; private final JTabbedPane tabControl = new JTabbedPane(); + private final JTabbedPane splitTabControl = new JTabbedPane(); + private final JSplitPane editorSplitPane; + private final JPanel primaryTabHost = new JPanel(new BorderLayout()); + private final JButton addTabDropdownButton = new JButton(createScaledIcon(ICON_ADD, 18)); + private final JPanel fileViewModeTabs = createSegmentedButtonStrip(); + private final JToggleButton editViewButton = + createFileViewModeButton("Editor", ICON_EDIT, IFileViewer.ViewMode.EDITOR, "first"); + private final JToggleButton editPreviewViewButton = createFileViewModeButton( + "Editor and Preview", ICON_EDIT_PREVIEW, IFileViewer.ViewMode.EDITOR_AND_PREVIEW, "middle"); + private final JToggleButton previewViewButton = + createFileViewModeButton("Preview", ICON_PREVIEW, IFileViewer.ViewMode.PREVIEW, "last"); + private final JPanel fileViewModeTabsHost = createSegmentedButtonStripHost(fileViewModeTabs); + private final JPanel centerPaneHost = new JPanel(new BorderLayout()); + private final JPanel topPaneHost = new JPanel(new BorderLayout()); + private final JPanel leftRailsHost = new JPanel(); + private final JSplitPane workspacePane; + private final JSplitPane contentPane; + private final JPanel bottomBarContainer = new JPanel(new BorderLayout()); + private final JPanel leftSidebarContent = new JPanel(new CardLayout()); + private final JToolBar leftSidebarRail = new JToolBar(SwingConstants.VERTICAL); + private final ButtonGroup leftSidebarGroup = new ButtonGroup(); + private final Map leftSidebarButtons = new HashMap<>(); + private final JPanel bottomBarContent = new JPanel(new CardLayout()); + private final JToolBar bottomBarRail = new JToolBar(SwingConstants.VERTICAL); + private final ButtonGroup bottomBarGroup = new ButtonGroup(); + private final Map bottomBarButtons = new HashMap<>(); + + private final JPanel rightDocsContainer = new JPanel(new BorderLayout()); + private final JPanel rightDocsContent = new JPanel(new CardLayout()); + private final JToolBar rightDocsRail = new JToolBar(SwingConstants.VERTICAL); + private final ButtonGroup rightDocsGroup = new ButtonGroup(); + private final Map rightDocsButtons = new HashMap<>(); + + private final JButton runTargetButton = new JButton(); + private final JPopupMenu runTargetPopup = new JPopupMenu(); + private boolean runTargetFollowsCurrentTab = true; + + private int expandedLeftSidebarWidth = 260; + private int expandedRightDocsWidth = 320; + private int expandedBottomBarHeight = 220; + private int workspacePaneDividerSize; + private int contentPaneDividerSize; + private int mainPaneDividerSize; + private boolean leftSidebarCollapsed; + private boolean rightDocsCollapsed; + private boolean bottomBarCollapsed; + private String activeLeftSidebarKey = "files"; + private String activeRightDocsKey; + private String activeBottomBarKey; private JPanel emptyTabPanel; + private final java.util.List recentWorkspaces = new ArrayList<>(); + private static final String RECENT_WORKSPACES_FILE = "recent-workspaces.properties"; + private static final String RECENT_WORKSPACES_KEY = "RECENT_WORKSPACES"; + private static final int MAX_RECENT_WORKSPACES = 10; + private static final Dimension TAB_HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); + private static final Dimension TAB_VIEW_MODE_BUTTON_SIZE = new Dimension(34, 30); + private static final String TAB_FILE_VIEWER_PROPERTY = "basic4gl.fileViewer"; + private static final Color SEGMENTED_BACKGROUND = new Color(0xE0E0E0); + private static final String SEGMENTED_BUTTON_STYLE = "arc: 14; borderWidth: 0; focusWidth: 0; innerFocusWidth: 0;" + + " margin: 6,8,6,8; background: #E0E0E0;" + + " hoverBackground: #D6D6D6; selectedBackground: #FFFFFF"; private final JMenu bookmarkSubMenu = new JMenu("Bookmarks"); private final JMenu breakpointSubMenu = new JMenu("Breakpoints"); @@ -89,8 +162,10 @@ public void caretUpdate(CaretEvent e) { // Menu Items private final JMenuItem newMenuItem = new JMenuItem("New Program"); private final JMenuItem openMenuItem = new JMenuItem("Open Program..."); + private final JMenuItem openFolderMenuItem = new JMenuItem("Open Folder..."); private final JMenuItem recentSubMenu = new JMenu("Open Recent"); private final JMenuItem clearRecentMenuItem = new JMenuItem("Clear Recently Opened..."); + private final JMenuItem clearRecentWorkspacesMenuItem = new JMenuItem("Clear Recent Workspaces..."); private final JMenuItem saveMenuItem = new JMenuItem("Save"); private final JMenuItem saveAsMenuItem = new JMenuItem("Save As..."); private final JMenuItem exportMenuItem = new JMenuItem("Export..."); @@ -104,6 +179,7 @@ public void caretUpdate(CaretEvent e) { private final JMenuItem findMenuItem = new JMenuItem("Find"); private final JMenuItem replaceMenuItem = new JMenuItem("Replace"); + private final JMenuItem goToDeclarationMenuItem = new JMenuItem("Go to Declaration"); private final JCheckBoxMenuItem debugMenuItem = new JCheckBoxMenuItem("Debug Mode"); private final JMenuItem settingsMenuItem = new JMenuItem("Project Settings"); @@ -120,27 +196,18 @@ public void caretUpdate(CaretEvent e) { private final JButton saveButton = new JButton(createImageIcon(ICON_SAVE)); private final JButton runButton = new JButton(createImageIcon(ICON_RUN_APP)); - private final JToggleButton debugButton = new JToggleButton(createImageIcon(ICON_DEBUG)); - private final JButton playButton = new JButton(createImageIcon(ICON_PLAY)); - private final JButton stepOverButton = new JButton(createImageIcon(ICON_STEP_OVER)); - private final JButton stepInButton = new JButton(createImageIcon(ICON_STEP_IN)); - private final JButton stepOutButton = new JButton(createImageIcon(ICON_STEP_OUT)); private final JButton exportButton = new JButton(createImageIcon(ICON_EXPORT)); private final JButton settingsButton = new JButton(createImageIcon(ICON_SETTINGS)); - private final JSeparator debugSeparator = new JSeparator(JSeparator.VERTICAL); // Labels private final JLabel compilerStatusLabel = new JLabel(""); // Compiler/VM Status private final JLabel cursorPositionLabel = new JLabel("0:0"); // Cursor Position - // Debugging - private final DefaultListModel watchListModel = new DefaultListModel<>(); - private final JList watchListBox = new JList<>(watchListModel); - private final DefaultListModel gosubListModel = new DefaultListModel<>(); - // Editors private BasicEditor basicEditor; private FileManager fileManager; + IEditorPanelProvider[] panels; + private IncludeLinkGenerator linkGenerator = new IncludeLinkGenerator(this); private SearchContext searchContext; @@ -155,8 +222,8 @@ public void caretUpdate(CaretEvent e) { private final Map tabAutoCompletions = new IdentityHashMap<>(); // Debugging - private boolean isDebugMode = false; private VirtualMachineViewDialog virtualMachineViewDialog; + private IDebugPresenter debugPresenter; private int lastSourceRow = -1; private int lastSourceColumn = -1; @@ -193,7 +260,7 @@ public static void main(String[] args) { String appHome = System.getenv("APP_HOME"); // APP_HOME is defined in scripts distributed with build if (appHome != null && !appHome.trim().isEmpty()) { File appDirectory = new File(appHome); - File outputBin = new File(appDirectory, "lib/library-1.0-SNAPSHOT.jar"); + File outputBin = new File(appDirectory, "lib/app-runtime-1.0-SNAPSHOT.jar"); File debugServerBin = new File(appDirectory, "lib/debug-server-1.0-SNAPSHOT.jar"); if (outputBin.exists()) { @@ -218,6 +285,7 @@ public static void main(String[] args) { System.setProperty("com.apple.mrj.application.apple.menu.about.name", "Basic4GLj"); FlatLightLaf.setup(); + UIManager.put("SplitPaneDivider.style", "plain"); PrintStream out = null; try { @@ -273,6 +341,11 @@ public MainWindow() { frame.setPreferredSize(new Dimension(696, 480)); frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + mainPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + editorSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + workspacePane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + contentPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + JMenuBar menuBar = new JMenuBar(); JMenu fileMenu = new JMenu("File"); menuBar.add(fileMenu); @@ -289,6 +362,7 @@ public MainWindow() { fileMenu.add(newMenuItem); fileMenu.add(openMenuItem); + fileMenu.add(openFolderMenuItem); fileMenu.add(recentSubMenu); fileMenu.add(new JSeparator()); fileMenu.add(saveMenuItem); @@ -305,6 +379,7 @@ public MainWindow() { editMenu.add(new JSeparator()); editMenu.add(findMenuItem); editMenu.add(replaceMenuItem); + editMenu.add(goToDeclarationMenuItem); editMenu.add(new JSeparator()); editMenu.add(selectAllMenuItem); @@ -356,8 +431,12 @@ public MainWindow() { newMenuItem.addActionListener(e -> actionNew()); openMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_O, toolkit.getMenuShortcutKeyMask())); openMenuItem.addActionListener(e -> actionOpen()); + openFolderMenuItem.setAccelerator( + KeyStroke.getKeyStroke(KeyEvent.VK_O, toolkit.getMenuShortcutKeyMask() | InputEvent.SHIFT_MASK)); + openFolderMenuItem.addActionListener(e -> actionOpenFolder()); saveMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_S, toolkit.getMenuShortcutKeyMask())); clearRecentMenuItem.addActionListener(e -> actionClearRecent()); + clearRecentWorkspacesMenuItem.addActionListener(e -> actionClearRecentWorkspaces()); saveMenuItem.addActionListener(e -> actionSave()); saveAsMenuItem.addActionListener(e -> actionSaveAs()); exportMenuItem.addActionListener(e -> actionExport()); @@ -395,6 +474,8 @@ public MainWindow() { replaceMenuItem.addActionListener(e -> { showFindReplaceMenu(true); }); + goToDeclarationMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_B, toolkit.getMenuShortcutKeyMask())); + goToDeclarationMenuItem.addActionListener(e -> actionGoToDeclaration()); selectAllMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_A, toolkit.getMenuShortcutKeyMask())); selectAllMenuItem.addActionListener(e -> { int i = tabControl.getSelectedIndex(); @@ -493,6 +574,11 @@ public void onStepOutRequested() { settingsMenuItem.addActionListener(e -> { showSettings(); }); + + functionListMenuItem.addActionListener(e -> { + selectRightDocsSection("symbols"); + }); + aboutMenuItem.addActionListener(e -> showAboutDialog()); if (SystemInfo.isMacOS) { @@ -502,78 +588,15 @@ public void onStepOutRequested() { // TODO mExitMenuItem.setVisible(false); } - // Debugger - JPanel watchListFrame = new JPanel(); - watchListFrame.setLayout(new BorderLayout()); - JLabel watchlistLabel = new JLabel("Watchlist"); - watchlistLabel.setBorder(new EmptyBorder(4, 8, 4, 8)); - watchListFrame.add(watchlistLabel, BorderLayout.NORTH); - JScrollPane watchListScrollPane = new JScrollPane(watchListBox); - watchListFrame.add(watchListScrollPane, BorderLayout.CENTER); - - watchListBox.addMouseListener(new MouseAdapter() { - public void mouseClicked(MouseEvent evt) { - JList list = (JList) evt.getSource(); - if (evt.getClickCount() == 2) { - // Double-click detected - int index = list.locationToIndex(evt.getPoint()); - editWatch(); - } - } - }); - - watchListBox.addKeyListener(new KeyListener() { - @Override - public void keyTyped(KeyEvent e) {} - - @Override - public void keyPressed(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_ENTER) { - editWatch(); - } - } - - @Override - public void keyReleased(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_DELETE) { - deleteWatch(); - } else if (e.getKeyCode() == KeyEvent.VK_INSERT) { - watchListBox.setSelectedIndex(basicEditor.getWatchListSize()); - editWatch(); - } - } - }); - - watchListBox.addListSelectionListener(e -> updateWatchHint()); - - JPanel gosubFrame = new JPanel(); - gosubFrame.setLayout(new BorderLayout()); - JLabel callstackLabel = new JLabel("Callstack"); - callstackLabel.setBorder(new EmptyBorder(4, 8, 4, 8)); - gosubFrame.add(callstackLabel, BorderLayout.NORTH); - JList gosubListBox = new JList<>(gosubListModel); - JScrollPane gosubListScrollPane = new JScrollPane(gosubListBox); - gosubFrame.add(gosubListScrollPane, BorderLayout.CENTER); - - debugPane.setLeftComponent(watchListFrame); - debugPane.setRightComponent(gosubFrame); - // Toolbar JToolBar toolBar = new JToolBar(); toolBar.add(newButton); toolBar.add(openButton); toolBar.add(saveButton); - toolBar.addSeparator(); + toolBar.add(Box.createHorizontalGlue()); + toolBar.add(runTargetButton); toolBar.add(runButton); toolBar.addSeparator(); - toolBar.add(debugButton); - toolBar.addSeparator(); - toolBar.add(playButton); - toolBar.add(stepOverButton); - toolBar.add(stepInButton); - toolBar.add(stepOutButton); - toolBar.add(debugSeparator); - toolBar.add(Box.createHorizontalGlue()); toolBar.add(exportButton); toolBar.add(settingsButton); @@ -582,14 +605,22 @@ public void keyReleased(KeyEvent e) { saveButton.addActionListener(e -> actionSave()); runButton.addActionListener(e -> basicEditor.actionRun()); - debugButton.addActionListener(e -> actionDebugMode()); - playButton.addActionListener(e -> basicEditor.actionPlayPause()); - stepOverButton.addActionListener(e -> basicEditor.actionStep()); - stepInButton.addActionListener(e -> basicEditor.actionStepInto()); - stepOutButton.addActionListener(e -> basicEditor.actionStepOutOf()); exportButton.addActionListener(e -> actionExport()); settingsButton.addActionListener(e -> showSettings()); runButton.setToolTipText("Run the program!"); + runTargetButton.setToolTipText("Select the runnable source file"); + runTargetButton.setMaximumSize(new Dimension(260, 30)); + runTargetButton.setFocusable(false); + runTargetButton.setIcon(createScaledIcon(ICON_CHEVRON_DOWN, 18)); + runTargetButton.setHorizontalTextPosition(SwingConstants.LEFT); + runTargetButton.setIconTextGap(6); + runTargetButton.putClientProperty("JButton.buttonType", "toolBarButton"); + runTargetButton.setOpaque(false); + runTargetButton.setMargin(new Insets(5, 8, 5, 8)); + Font runTargetFont = runTargetButton.getFont(); + runTargetButton.setFont(new Font(runTargetFont.getName(), Font.BOLD, runTargetFont.getSize())); + runTargetButton.setForeground(new Color(0x424242)); + runTargetButton.addActionListener(e -> showRunTargetPopup()); toolBar.setAlignmentY(1); toolBar.setFloatable(false); @@ -643,16 +674,21 @@ protected void installDefaults() { if (fileCheckSaveChanges(tabIndex)) { // Clear file's breakpoints FileEditor editor = fileManager.getFileEditors().get(tabIndex); - List breakpoints = editor.getBreakpoints(); - String file = editor.getFilePath(); + if (editor != null) { + List breakpoints = editor.getBreakpoints(); + String file = editor.getFilePath(); - for (Integer line : breakpoints) { - basicEditor.toggleBreakpt(file, line); + for (Integer line : breakpoints) { + basicEditor.toggleBreakpt(file, line); + } } // Remove tab tabControl.remove(tabIndex); fileManager.getFileEditors().remove(tabIndex.intValue()); + refreshFileViewModeButtons(); + fileManager.ensureRunnableFileValid(); + refreshRunnableFileControls(); // Refresh controls if no files open if (fileManager.editorCount() == 0) { @@ -662,13 +698,54 @@ protected void installDefaults() { } }); - mainPane.setTopComponent(tabControl); - debugPane.setLeftComponent(watchListFrame); - debugPane.setRightComponent(gosubFrame); + configurePrimaryTabHost(); + configureSplitTabs(); + configureTabContextMenu(); + + editorSplitPane.setLeftComponent(primaryTabHost); + editorSplitPane.setRightComponent(splitTabControl); + editorSplitPane.setResizeWeight(0.7); + hideSplitPaneHandle(editorSplitPane); + + contentPane.setLeftComponent(primaryTabHost); + contentPane.setRightComponent(rightDocsContainer); + contentPane.setResizeWeight(0.74); + hideSplitPaneHandle(contentPane); + contentPaneDividerSize = contentPane.getDividerSize(); + + if (LEFT_SIDEBAR_ENABLED) { + workspacePane.setLeftComponent(leftSidebarContent); + workspacePane.setRightComponent(contentPane); + workspacePane.setResizeWeight(0.18); + workspacePane.setDividerLocation(expandedLeftSidebarWidth); + hideSplitPaneHandle(workspacePane); + workspacePaneDividerSize = workspacePane.getDividerSize(); + } + + contentPane.setDividerLocation(Math.max(200, frame.getPreferredSize().width - expandedRightDocsWidth)); + + topPaneHost.add(LEFT_SIDEBAR_ENABLED ? workspacePane : contentPane, BorderLayout.CENTER); + + mainPane.setTopComponent(topPaneHost); + mainPane.setBottomComponent(bottomBarContainer); + mainPane.setResizeWeight(1.0); + hideSplitPaneHandle(mainPane); + mainPaneDividerSize = mainPane.getDividerSize(); + + leftRailsHost.setLayout(new BoxLayout(leftRailsHost, BoxLayout.Y_AXIS)); + if (LEFT_SIDEBAR_ENABLED) { + leftRailsHost.add(leftSidebarRail); + } + leftRailsHost.add(Box.createVerticalGlue()); + leftRailsHost.add(bottomBarRail); + + centerPaneHost.add(leftRailsHost, BorderLayout.WEST); + centerPaneHost.add(mainPane, BorderLayout.CENTER); + centerPaneHost.add(rightDocsRail, BorderLayout.EAST); // Add controls to window frame.add(toolBar, BorderLayout.NORTH); - frame.add(mainPane, BorderLayout.CENTER); + frame.add(centerPaneHost, BorderLayout.CENTER); frame.add(statusPanel, BorderLayout.SOUTH); frame.setJMenuBar(menuBar); @@ -703,7 +780,21 @@ public void windowDeactivated(WindowEvent e) {} fileManager = new FileManager(this); - basicEditor = new BasicEditor(outputBinPath, fileManager, this, this); + basicEditor = new BasicEditor(outputBinPath, fileManager, this, new BasicDialogService(this.frame), this, this); + + debugPresenter = new DebugPanelProvider(basicEditor); + + panels = new IEditorPanelProvider[] { + new FileBrowserPanelProvider(), + new AssetsPanelProvider(fileManager), + new BookmarksPanelProvider(), + (IEditorPanelProvider) debugPresenter, + new SymbolsPanelProvider(), + new DocsPanelProvider(), + }; + + configureLeftSidebar(); + configureRightSidebar(); // TODO Confirm this doesn't break if app is ever signed // getParent @@ -726,6 +817,10 @@ public void windowDeactivated(WindowEvent e) {} basicEditor.initLibraries(); resetProject(); basicEditor.loadSettings(); + loadRecentWorkspaces(); + setRecentItems(basicEditor.getRecentFiles()); + refreshRunnableFileControls(); + refreshSidebarContent(); // Warm up the debug server DebugServerFactory.startDebugServer(debugServerBinPath, DebugServerConstants.DEFAULT_DEBUG_SERVER_PORT); @@ -734,6 +829,10 @@ public void windowDeactivated(WindowEvent e) {} frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); + SwingUtilities.invokeLater(() -> { + collapseBottomBar(); + collapseRightDocs(); + }); } private void actionExport() { @@ -756,6 +855,7 @@ private void actionExport() { frame, basicEditor.getCompiler(), basicEditor.getPreprocessor(), + basicEditor.getLanguageService(), fileManager.getFileEditors(), fileManager.getCurrentDirectory(), contributedExportPages); @@ -766,18 +866,50 @@ private void actionExport() { @Override public void setRecentItems(List files) { + List recentFiles = files == null ? Collections.emptyList() : files; recentSubMenu.removeAll(); - for (File file : files) { + + boolean hasRecentFiles = !recentFiles.isEmpty(); + boolean hasRecentWorkspaces = !recentWorkspaces.isEmpty(); + + if (hasRecentFiles) { + JMenuItem filesHeader = new JMenuItem("Recent Files"); + filesHeader.setEnabled(false); + recentSubMenu.add(filesHeader); + } + + for (File file : recentFiles) { JMenuItem fileMenuItem = new JMenuItem(file.getName()); recentSubMenu.add(fileMenuItem); fileMenuItem.addActionListener(e -> { - actionOpen(file); + openFileWithPreferredViewer(file); }); } - recentSubMenu.add(new JSeparator()); + + if (hasRecentFiles || hasRecentWorkspaces) { + recentSubMenu.add(new JSeparator()); + } + + clearRecentMenuItem.setEnabled(hasRecentFiles); recentSubMenu.add(clearRecentMenuItem); - clearRecentMenuItem.setEnabled(!files.isEmpty()); + + if (hasRecentWorkspaces) { + recentSubMenu.add(new JSeparator()); + JMenuItem workspacesHeader = new JMenuItem("Recent Workspaces"); + workspacesHeader.setEnabled(false); + recentSubMenu.add(workspacesHeader); + for (File workspace : recentWorkspaces) { + JMenuItem workspaceItem = new JMenuItem( + workspace.getName().isBlank() ? workspace.getAbsolutePath() : workspace.getName()); + workspaceItem.setToolTipText(workspace.getAbsolutePath()); + workspaceItem.addActionListener(e -> setWorkspaceDirectory(workspace)); + recentSubMenu.add(workspaceItem); + } + recentSubMenu.add(new JSeparator()); + recentSubMenu.add(clearRecentWorkspacesMenuItem); + } + clearRecentWorkspacesMenuItem.setEnabled(hasRecentWorkspaces); } @Override @@ -883,6 +1015,9 @@ private void tryCloseWindow() { // ShutDownTomWindowsBasicLib(); frame.dispose(); + for (IEditorPanelProvider panel : panels) { + panel.dispose(); + } symbolIndexer.shutdown(); System.exit(0); } @@ -894,12 +1029,16 @@ private void resetProject() { // Close existing editors tabControl.removeAll(); fileManager.getFileEditors().clear(); + refreshFileViewModeButtons(); // Create a default tab addTab(); // Display the editor tabControl.setSelectedIndex(0); + fileManager.ensureRunnableFileValid(); + refreshRunnableFileControls(); + refreshSidebarContent(); } @Override @@ -920,6 +1059,11 @@ public void setSelectedTabIndex(int index) { @Override public void openTab(String filename) { File file = new File(fileManager.getCurrentDirectory(), filename); + int existingIndex = findOpenTabIndexByPath(file.getAbsolutePath()); + if (existingIndex >= 0) { + tabControl.setSelectedIndex(existingIndex); + return; + } System.out.println("Open tab: " + filename); System.out.println("Path: " + file.getAbsolutePath()); @@ -930,12 +1074,34 @@ public void openTab(String filename) { } public void openTab(File file) { + if (file == null) { + return; + } + File absoluteFile = file.getAbsoluteFile(); + int existingIndex = findOpenTabIndexByPath(absoluteFile.getAbsolutePath()); + if (existingIndex >= 0) { + tabControl.setSelectedIndex(existingIndex); + return; + } + System.out.println("Open tab: " + file.getName()); System.out.println("Path: " + file.getAbsolutePath()); - MainWindow.this.addTab(FileEditor.open(file, this, fileManager, this, linkGenerator, searchContext)); + // Use the FileViewerFactory to determine the appropriate viewer + IFileViewer viewer = FileViewerFactory.createViewer( + absoluteFile, + null, // auto-detect viewer type + this, + fileManager, + this, + linkGenerator, + searchContext, + basicEditor); + + addTab(viewer); tabControl.setSelectedIndex(tabControl.getTabCount() - 1); + registerWorkspace(file.getParentFile()); } void actionNew() { @@ -947,8 +1113,10 @@ void actionNew() { // Clear file editors this.tabControl.removeAll(); fileManager.getFileEditors().clear(); + refreshFileViewModeButtons(); this.addTab(); + refreshSidebarContent(); } } @@ -958,11 +1126,14 @@ void actionOpen() { void actionOpen(File file) { if (multifileCheckSaveChanges()) { - FileEditor editor = null; if (file != null) { fileManager.setCurrentDirectory(fileManager.getFileDirectory()); - editor = FileEditor.open(file, this, fileManager, this, linkGenerator, searchContext); - } else { + openFileWithPreferredViewer(file); + return; + } + + FileEditor editor; + { fileManager.setCurrentDirectory(fileManager.getFileDirectory()); editor = FileEditor.open(frame, this, fileManager, this, linkGenerator, searchContext); } @@ -971,6 +1142,32 @@ void actionOpen(File file) { } } + @Override + public void actionOpenFolder() { + JFileChooser chooser = new JFileChooser(fileManager.getCurrentDirectory()); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + chooser.setAcceptAllFileFilterUsed(false); + int result = chooser.showOpenDialog(frame); + if (result != JFileChooser.APPROVE_OPTION) { + return; + } + setWorkspaceDirectory(chooser.getSelectedFile()); + } + + @Override + public void openFileWithPreferredViewer(File file) { + if (file == null) { + return; + } + File absoluteFile = file.getAbsoluteFile(); + int existingIndex = findOpenTabIndexByPath(absoluteFile.getAbsolutePath()); + if (existingIndex >= 0) { + tabControl.setSelectedIndex(existingIndex); + return; + } + openTab(absoluteFile); + } + void openEditor(FileEditor editor) { if (editor != null) { // TODO Check if file should open as new tab or project @@ -985,9 +1182,11 @@ void openEditor(FileEditor editor) { fileManager.setRunDirectory(fileManager.getFileDirectory()); fileManager.setCurrentDirectory(fileManager.getRunDirectory()); + registerWorkspace(new File(fileManager.getRunDirectory())); // Display file addTab(editor); + refreshSidebarContent(); } } @@ -1004,10 +1203,29 @@ void actionClearRecent() { } } + void actionClearRecentWorkspaces() { + int result = JOptionPane.showConfirmDialog( + frame, + "Clear recently opened workspaces?", + "Confirm", + JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE); + + if (result == JOptionPane.YES_OPTION) { + recentWorkspaces.clear(); + saveRecentWorkspaces(); + setRecentItems(basicEditor.getRecentFiles()); + refreshEmptyStateRecentItems(); + } + } + boolean fileCheckSaveChanges(int index) { // Is sub-file modified? FileEditor editor = fileManager.getFileEditors().get(index); + if (editor == null) { + return true; + } if (editor.isModified()) { int result = JOptionPane.showConfirmDialog( frame, @@ -1084,16 +1302,15 @@ boolean actionSave() { basicEditor.onFileSaving(fileManager.getFileEditors().get(index)); boolean saved = fileManager.getFileEditors().get(index).save(false, fileManager.getCurrentDirectory()); if (saved) { - // TODO Check if index of main file - int main = 0; + int main = fileManager.getRunnableFileIndex(); if (index == main) { fileManager.setFileDirectory( new File(fileManager.getFileEditors().get(index).getFilePath()).getParent()); fileManager.setRunDirectory(fileManager.getFileDirectory()); fileManager.setCurrentDirectory(fileManager.getRunDirectory()); } - tabControl.setTitleAt(index, fileManager.getFileEditors().get(index).getTitle()); - tabControl.getTabComponentAt(index).invalidate(); + refreshTabTitle(index); + refreshRunnableFileControls(); } return saved; } @@ -1109,16 +1326,15 @@ boolean actionSave(int index) { basicEditor.onFileSaving(fileManager.getFileEditors().get(index)); boolean saved = fileManager.getFileEditors().get(index).save(false, fileManager.getCurrentDirectory()); if (saved) { - // TODO Check if main file - int main = 0; + int main = fileManager.getRunnableFileIndex(); if (index == main) { fileManager.setFileDirectory( new File(fileManager.getFileEditors().get(index).getFilePath()).getParent()); fileManager.setRunDirectory(fileManager.getFileDirectory()); fileManager.setCurrentDirectory(fileManager.getRunDirectory()); } - tabControl.setTitleAt(index, fileManager.getFileEditors().get(index).getTitle()); - tabControl.getTabComponentAt(index).invalidate(); + refreshTabTitle(index); + refreshRunnableFileControls(); } return saved; } @@ -1136,8 +1352,7 @@ void actionSaveAs() { basicEditor.onFileSaving(fileManager.getFileEditors().get(index)); if (fileManager.getFileEditors().get(index).save(true, fileManager.getCurrentDirectory())) { - // TODO get current main file - int main = 0; + int main = fileManager.getRunnableFileIndex(); if (index == main) { fileManager.setFileDirectory( new File(fileManager.getFileEditors().get(index).getFilePath()).getParent()); @@ -1149,17 +1364,226 @@ void actionSaveAs() { // Restore Current directory fileManager.setCurrentDirectory(fileManager.getRunDirectory()); } + refreshTabTitle(index); + refreshRunnableFileControls(); + } + + private void refreshTabTitle(int index) { tabControl.setTitleAt(index, fileManager.getFileEditors().get(index).getTitle()); - tabControl.getTabComponentAt(index).invalidate(); + + Component tabComponent = tabControl.getTabComponentAt(index); + if (tabComponent != null) { + tabComponent.invalidate(); + } + tabControl.revalidate(); + tabControl.repaint(); } private void actionDebugMode() { - // Toggle debug mode - isDebugMode = !isDebugMode; - debugMenuItem.setSelected(isDebugMode); - debugButton.setSelected(isDebugMode); + if (Objects.equals(activeBottomBarKey, "debug") && isBottomBarExpanded()) { + collapseBottomBar(); + return; + } + selectBottomBarSection("debug", true); + } - refreshDebugDisplays(basicEditor.getMode()); + private void actionGoToDeclaration() { + int selectedTab = tabControl.getSelectedIndex(); + if (selectedTab < 0 || selectedTab >= fileManager.getFileEditors().size()) { + return; + } + + FileEditor activeEditor = fileManager.getFileEditors().get(selectedTab); + JTextArea editorPane = activeEditor.getEditorPane(); + String symbol = getIdentifierAtCaret(editorPane); + if (symbol == null || symbol.isBlank()) { + setCompilerStatus("No identifier at caret"); + return; + } + + String activeFile = activeEditor.getFilePath(); + int caretLine = 0; + try { + caretLine = editorPane.getLineOfOffset(editorPane.getCaretPosition()); + } catch (BadLocationException ignored) { + } + + java.util.List declarations = + collectOpenFileDeclarations(); + java.util.List matches = declarations.stream() + .filter(d -> ("label".equals(d.kind()) || "variable".equals(d.kind())) + && d.name().equalsIgnoreCase(symbol)) + .toList(); + + if (matches.isEmpty()) { + setCompilerStatus("Declaration not found for: " + symbol); + return; + } + + com.basic4gl.desktop.spi.language.SymbolDeclaration selected = + chooseDeclarationForCaret(matches, activeFile, caretLine); + if (selected == null) { + return; + } + + if (matches.size() > 1) { + selected = promptUserForDeclaration(matches, selected); + if (selected == null) { + return; + } + } + + goToDeclarationLocation(selected); + setCompilerStatus("Declaration: " + selected.signature()); + } + + private java.util.List collectOpenFileDeclarations() { + java.util.List declarations = new ArrayList<>(); + for (FileEditor editor : fileManager.getFileEditors()) { + String fileId = editor.getFilePath(); + if (fileId == null || fileId.isBlank()) { + File file = editor.getFile(); + fileId = file != null ? file.getAbsolutePath() : ""; + } + declarations.addAll(basicEditor + .getLanguageSupport() + .extractDeclarations(editor.getEditorPane().getText(), fileId)); + } + return declarations; + } + + private com.basic4gl.desktop.spi.language.SymbolDeclaration chooseDeclarationForCaret( + java.util.List matches, + String activeFile, + int caretLine) { + java.util.List sameFile = matches.stream() + .filter(d -> Objects.equals(d.fileId(), activeFile)) + .toList(); + java.util.List candidates = + sameFile.isEmpty() ? matches : sameFile; + + com.basic4gl.desktop.spi.language.SymbolDeclaration best = null; + int bestScore = Integer.MAX_VALUE; + for (com.basic4gl.desktop.spi.language.SymbolDeclaration candidate : candidates) { + int score = declarationScore(candidate, activeFile, caretLine); + if (score < bestScore) { + bestScore = score; + best = candidate; + } + } + return best; + } + + private int declarationScore( + com.basic4gl.desktop.spi.language.SymbolDeclaration declaration, String activeFile, int caretLine) { + int score = 0; + if (!Objects.equals(declaration.fileId(), activeFile)) { + score += 1_000_000; + } + int lineDistance = Math.abs(caretLine - declaration.line()); + score += lineDistance; + if (declaration.line() > caretLine) { + // Prefer declarations above the current caret location. + score += 5_000; + } + // Prefer first declaration over later re-dims when ambiguous. + score += Math.max(0, declaration.declarationIndex() - 1) * 50; + return score; + } + + private com.basic4gl.desktop.spi.language.SymbolDeclaration promptUserForDeclaration( + java.util.List matches, + com.basic4gl.desktop.spi.language.SymbolDeclaration preferred) { + Object[] options = matches.stream().map(this::formatDeclarationChoice).toArray(); + Object initial = + preferred != null ? formatDeclarationChoice(preferred) : (options.length > 0 ? options[0] : null); + Object selected = JOptionPane.showInputDialog( + frame, + "Multiple declarations found. Choose destination:", + "Go to Declaration", + JOptionPane.QUESTION_MESSAGE, + null, + options, + initial); + if (selected == null) { + return null; + } + String selectedText = selected.toString(); + for (com.basic4gl.desktop.spi.language.SymbolDeclaration declaration : matches) { + if (formatDeclarationChoice(declaration).equals(selectedText)) { + return declaration; + } + } + return preferred; + } + + private String formatDeclarationChoice(com.basic4gl.desktop.spi.language.SymbolDeclaration declaration) { + String fileLabel = declaration.fileId(); + File f = fileLabel == null ? null : new File(fileLabel); + if (f != null && f.getName() != null && !f.getName().isBlank()) { + fileLabel = f.getName(); + } + return declaration.kind() + " " + declaration.signature() + " (" + fileLabel + ":" + (declaration.line() + 1) + + ")"; + } + + private void goToDeclarationLocation(com.basic4gl.desktop.spi.language.SymbolDeclaration declaration) { + String filePath = declaration.fileId(); + int index = getTabIndex(filePath); + if (index == -1 && filePath != null && !filePath.startsWith("= fileManager.getFileEditors().size()) { + return; + } + + tabControl.setSelectedIndex(index); + JTextArea pane = fileManager.getFileEditors().get(index).getEditorPane(); + int targetOffset; + try { + int lineStart = pane.getLineStartOffset(Math.max(0, declaration.line())); + targetOffset = Math.min( + lineStart + Math.max(0, declaration.column()), + pane.getDocument().getLength()); + } catch (BadLocationException e) { + targetOffset = Math.min(pane.getDocument().getLength(), pane.getCaretPosition()); + } + pane.requestFocusInWindow(); + pane.setCaretPosition(targetOffset); + } + + private String getIdentifierAtCaret(JTextArea editorPane) { + String text = editorPane.getText(); + if (text == null || text.isEmpty()) { + return null; + } + int caret = editorPane.getCaretPosition(); + caret = Math.max(0, Math.min(caret, text.length())); + + if (caret > 0 && (caret == text.length() || !isIdentifierChar(text.charAt(caret)))) { + caret--; + } + if (caret < 0 || caret >= text.length() || !isIdentifierChar(text.charAt(caret))) { + return null; + } + + int start = caret; + while (start > 0 && isIdentifierChar(text.charAt(start - 1))) { + start--; + } + int end = caret + 1; + while (end < text.length() && isIdentifierChar(text.charAt(end))) { + end++; + } + return text.substring(start, end); + } + + private boolean isIdentifierChar(char c) { + return Character.isLetterOrDigit(c) || c == '_'; } public void closeAll() { @@ -1175,12 +1599,21 @@ public void closeAll() { // Refresh UI refreshActions(basicEditor.getMode()); + refreshRunnableFileControls(); + refreshSidebarContent(); } public void closeTab(int index) { tabControl.remove(index); - FileEditor closed = fileManager.getFileEditors().remove(index); - tabAutoCompletions.remove(closed); + if (index >= 0 && index < fileManager.getFileEditors().size()) { + FileEditor closed = fileManager.getFileEditors().remove(index); + tabAutoCompletions.remove(closed); + } + refreshFileViewModeButtons(); + fileManager.ensureRunnableFileValid(); + refreshRunnableFileControls(); + refreshSidebarContent(); + // Re-index so completions drop symbols that only existed in the closed file. symbolIndexer.indexNow(); } @@ -1191,85 +1624,136 @@ public void addTab() { } public void addTab(FileEditor editor) { + addTab(new TextFileViewer(editor)); + } + + /** + * Adds a file viewer tab for all viewer types, including text editors, images, audio, and docs. + */ + public void addTab(IFileViewer viewer) { + int count = tabControl.getTabCount(); + FileViewerWrapper wrapper = new FileViewerWrapper(viewer); - int count = fileManager.editorCount(); - fileManager.getFileEditors().add(editor); + // For backward compatibility with FileEditor code, also add to fileManager if it's a text editor + if (wrapper.isTextEditor()) { + fileManager.getFileEditors().add(wrapper.getFileEditor()); + } else { + // Add a placeholder to keep indices aligned + fileManager.getFileEditors().add(null); + } // replace emptyTabPanel if needed - mainPane.setTopComponent(tabControl); + setEditorContent(getActiveEditorHost()); - tabControl.addTab(editor.getTitle(), editor.getContentPane()); + JComponent contentPane = viewer.getContentPane(); + contentPane.putClientProperty(TAB_FILE_VIEWER_PROPERTY, viewer); + tabControl.addTab(viewer.getTitle(), contentPane); - final FileEditor edit = editor; - File file = edit.getFile(); + File file = viewer.getFile(); if (file != null) { basicEditor.notifyFileOpened(file); - basicEditor.onFileOpened(edit); + if (wrapper.isTextEditor()) { + basicEditor.onFileOpened(wrapper.getFileEditor()); + } } - edit.getEditorPane().getDocument().addDocumentListener(new DocumentListener() { - @Override - public void insertUpdate(DocumentEvent e) { - int index = getTabIndex(edit.getFilePath()); - edit.setModified(); - tabControl.setTitleAt(index, edit.getTitle()); - // mTabControl.getTabComponentAt(index).invalidate(); - symbolIndexer.schedule(); - } + // Add document listener only for text editors + if (wrapper.isTextEditor()) { + final FileEditor edit = wrapper.getFileEditor(); + JTextArea editorPane = edit.getEditorPane(); + editorPane.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + int index = getTabIndex(edit.getFilePath()); + edit.setModified(); + tabControl.setTitleAt(index, edit.getTitle()); + + for (IEditorPanelProvider panel : panels) { + panel.onFileModified(edit.getFilePath()); + } - @Override - public void removeUpdate(DocumentEvent e) { - int index = getTabIndex(edit.getFilePath()); - edit.setModified(); - tabControl.setTitleAt(index, edit.getTitle()); - symbolIndexer.schedule(); - } + symbolIndexer.schedule(); + } - @Override - public void changedUpdate(DocumentEvent e) { - // RSyntaxDocument fires a 0..length CHANGE when the token maker is swapped. - // Plain-text model: no characters moved. Ignore changes for modification flag. - int index = getTabIndex(edit.getFilePath()); - tabControl.setTitleAt(index, edit.getTitle()); - } - }); + @Override + public void removeUpdate(DocumentEvent e) { + int index = getTabIndex(edit.getFilePath()); + edit.setModified(); + tabControl.setTitleAt(index, edit.getTitle()); - // Install code completion driven by the shared symbol-backed provider. Ctrl+Space (or the - // platform equivalent) triggers the popup; the provider content is refreshed by the - // SymbolIndexer callback as the user types. - AutoCompletion autoCompletion = new NonRetriggeringAutoCompletion(completionProvider); - autoCompletion.setAutoActivationDelay(500); - // Contextual filtering (e.g. gosub/goto -> labels) often narrows the popup down to a single - // candidate; without this, that candidate would be inserted silently with no visible hint. - autoCompletion.setAutoCompleteSingleChoices(false); - // Render signature/description inline in the popup list (see SymbolCompletionCellRenderer) - // instead of a separate description window, so the whole popup stays one FlatLaf-styled - // component rather than pairing it with the library's non-themable description window. - autoCompletion.setListCellRenderer(new SymbolCompletionCellRenderer()); - autoCompletion.setShowDescWindow(false); - tabAutoCompletions.put(editor, autoCompletion); - applyCompletionSettingsTo(autoCompletion); - autoCompletion.install(editor.getEditorPane()); - - // Allow user to see cursor position - editor.getEditorPane().addCaretListener(TrackCaretPosition); - cursorPositionLabel.setText(0 + ":" + 0); // Reset label - - // Set tab as read-only if App is running or paused - boolean readOnly = basicEditor.getMode() != ApMode.AP_STOPPED; - editor.getEditorPane().setEditable(!readOnly); - - // TODO set syntax highlight colors - - // Refresh interface if there was previously no tabs open + for (IEditorPanelProvider panel : panels) { + panel.onFileModified(edit.getFilePath()); + } + + symbolIndexer.schedule(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + // RSyntaxDocument fires a 0..length CHANGE when the token maker is swapped. + // Plain-text model: no characters moved. Ignore changes for modification flag. + int index = getTabIndex(edit.getFilePath()); + tabControl.setTitleAt(index, edit.getTitle()); + } + }); + + // Install code completion driven by the shared symbol-backed provider. Ctrl+Space (or the + // platform equivalent) triggers the popup; the provider content is refreshed by the + // SymbolIndexer callback as the user types. + AutoCompletion autoCompletion = new NonRetriggeringAutoCompletion(completionProvider); + autoCompletion.setAutoActivationDelay(500); + // Contextual filtering (e.g. gosub/goto -> labels) often narrows the popup down to a single + // candidate; without this, that candidate would be inserted silently with no visible hint. + autoCompletion.setAutoCompleteSingleChoices(false); + // Render signature/description inline in the popup list (see SymbolCompletionCellRenderer) + // instead of a separate description window, so the whole popup stays one FlatLaf-styled + // component rather than pairing it with the library's non-themable description window. + autoCompletion.setListCellRenderer(new SymbolCompletionCellRenderer()); + autoCompletion.setShowDescWindow(false); + tabAutoCompletions.put(edit, autoCompletion); + applyCompletionSettingsTo(autoCompletion); + autoCompletion.install(edit.getEditorPane()); + + // Allow user to see cursor position + editorPane.addCaretListener(TrackCaretPosition); + cursorPositionLabel.setText(0 + ":" + 0); // Reset label + + // Set tab as read-only if App is running or paused + boolean readOnly = basicEditor.getMode() != ApMode.AP_STOPPED; + editorPane.setEditable(!readOnly); + } + + // Refresh interface if there were previously no tabs open if (count == 0) { basicEditor.setMode(ApMode.AP_STOPPED, null); } + fileManager.ensureRunnableFileValid(); + refreshRunnableFileControls(); + refreshSidebarContent(); // Populate completions for the newly opened source without waiting for the first edit. symbolIndexer.indexNow(); } + public void openDocumentationPreview(ContentDocumentViewer viewer) { + int existingPreviewIndex = findUnpinnedDocumentationPreviewTab(); + if (existingPreviewIndex >= 0) { + closeTab(existingPreviewIndex); + } + addTab(viewer); + tabControl.setSelectedIndex(tabControl.getTabCount() - 1); + } + + private int findUnpinnedDocumentationPreviewTab() { + for (int i = 0; i < tabControl.getTabCount(); i++) { + IFileViewer viewer = getFileViewerAt(i); + if (viewer instanceof ContentDocumentViewer documentViewer && !documentViewer.isPinned()) { + return i; + } + } + return -1; + } + /** * Concatenates the text of every open editor into a single source snapshot for the * {@link SymbolIndexer}. Concatenation lets symbol extraction (and therefore code completion) @@ -1355,10 +1839,8 @@ public void onPause() { basicEditor.setMode(ApMode.AP_PAUSED, null); refreshActions(basicEditor.getMode()); - // Place editor into debug mode - isDebugMode = true; - debugMenuItem.setSelected(true); - debugButton.setSelected(true); + // Open debug panel when execution pauses. + selectBottomBarSection("debug", true); refreshDebugDisplays(basicEditor.getMode()); // TODO Add VMViewer @@ -1384,21 +1866,31 @@ public void setCompilerStatus(String error) { compilerStatusLabel.setText(error); } + @Override + public void updateCallStack(StackTraceCallback message) { + debugPresenter.updateCallStack(message); + } + + @Override + public void onCompileSucceeded() { + + for (IEditorPanelProvider panel : panels) { + panel.onCompileSucceeded(); + } + } + @Override public void onModeChanged(ApMode mode, String statusMsg) { if (mode != ApMode.AP_CLOSED) { copyMenuItem.setEnabled(true); findMenuItem.setEnabled(true); replaceMenuItem.setEnabled(true); + goToDeclarationMenuItem.setEnabled(true); selectAllMenuItem.setEnabled(true); - stepOverButton.setEnabled(true); - stepInButton.setEnabled(true); - stepOutButton.setEnabled(true); stepOverMenuItem.setEnabled(true); stepIntoMenuItem.setEnabled(true); stepOutOfMenuItem.setEnabled(true); - playButton.setEnabled(true); playPauseMenuItem.setEnabled(true); runMenuItem.setEnabled(true); runButton.setEnabled(true); @@ -1448,15 +1940,12 @@ public void refreshActions(ApMode mode) { copyMenuItem.setEnabled(false); findMenuItem.setEnabled(false); replaceMenuItem.setEnabled(false); + goToDeclarationMenuItem.setEnabled(false); selectAllMenuItem.setEnabled(false); - stepOverButton.setEnabled(false); - stepInButton.setEnabled(false); - stepOutButton.setEnabled(false); stepOverMenuItem.setEnabled(false); stepIntoMenuItem.setEnabled(false); stepOutOfMenuItem.setEnabled(false); - playButton.setEnabled(false); playPauseMenuItem.setEnabled(false); runMenuItem.setEnabled(false); runButton.setEnabled(false); @@ -1474,10 +1963,13 @@ public void refreshActions(ApMode mode) { this, newMenuItem.getAccelerator(), openMenuItem.getAccelerator(), - basicEditor.getRecentFiles()); - mainPane.setTopComponent(emptyTabPanel); + openFolderMenuItem.getAccelerator(), + basicEditor.getRecentFiles(), + recentWorkspaces); + setEditorContent(emptyTabPanel); break; case AP_STOPPED: + setEditorContent(getActiveEditorHost()); setClosingTabsEnabled(true); settingsMenuItem.setEnabled(true); settingsButton.setEnabled(true); @@ -1517,6 +2009,7 @@ public void refreshActions(ApMode mode) { case AP_RUNNING: case AP_PAUSED: + setEditorContent(getActiveEditorHost()); setClosingTabsEnabled(false); settingsMenuItem.setEnabled(false); @@ -1544,60 +2037,18 @@ public void refreshActions(ApMode mode) { @Override public void refreshDebugDisplays(ApMode mode) { + debugPresenter.refreshDebugControls(mode); + playPauseMenuItem.setEnabled(mode != ApMode.AP_WAITING && mode != ApMode.AP_CLOSED); - // Show/hide debug controls - playButton.setVisible(isDebugMode); - stepOverButton.setVisible(isDebugMode); - stepInButton.setVisible(isDebugMode); - stepOutButton.setVisible(isDebugMode); - debugSeparator.setVisible(isDebugMode); - - // TODO Show/hide debug pane - if (isDebugMode) { - mainPane.setResizeWeight(0.7); - // mDebugPane.setEnabled(true); - mainPane.setEnabled(true); - mainPane.setBottomComponent(debugPane); - SwingUtilities.invokeLater(() -> debugPane.setDividerLocation(0.7)); - } else { - mainPane.remove(debugPane); - // mDebugPane.setEnabled(false); - mainPane.setEnabled(false); - } - - if (mode != ApMode.AP_CLOSED) { - playButton.setIcon(mode == ApMode.AP_RUNNING ? createImageIcon(ICON_PAUSE) : createImageIcon(ICON_PLAY)); - playButton.setEnabled(mode != ApMode.AP_WAITING); - playPauseMenuItem.setEnabled(mode != ApMode.AP_WAITING); - stepOverButton.setEnabled(mode != ApMode.AP_RUNNING && mode != ApMode.AP_WAITING); - stepInButton.setEnabled(mode != ApMode.AP_RUNNING && mode != ApMode.AP_WAITING); - - // TODO 12/2022 determine appropriate state for mStepOutButton; - // does the editor even need to care about UserCallStack size with remote debugger protocol - // setup? - stepOutButton.setEnabled(mode == ApMode.AP_PAUSED); - // TODO old mStepOutButton.setEnabled(mode == ApMode.AP_PAUSED && - // (mEditor.mVM.UserCallStack().size() > 0)); - } - if (!isDebugMode) { - return; + if (mode == ApMode.AP_CLOSED && isBottomBarExpanded()) { + collapseBottomBar(); } if (mode != ApMode.AP_PAUSED) { - // Clear debug controls - gosubListModel.clear(); + debugPresenter.clearCallStack(); } - } - - @Override - public void updateCallStack(StackTraceCallback stackTraceCallback) { - // Clear debug controls - gosubListModel.clear(); - - for (String label : basicEditor.getLanguageService().buildFriendlyCallStackLabels(stackTraceCallback)) { - gosubListModel.addElement(label); - } + syncDebugMenuSelection(); } @Override @@ -1624,13 +2075,7 @@ public void updateVmViewVariables(VariablesCallback variablesCallback) { @Override public void updateEvaluateWatch(String evaluatedWatch, String result) { - int index = 0; - for (String watch : basicEditor.getWatches()) { - if (Objects.equals(watch, evaluatedWatch)) { - watchListModel.setElementAt(watch + ": " + result, index); - } - index++; - } + debugPresenter.updateEvaluateWatch(evaluatedWatch, result); } @Override @@ -1649,77 +2094,931 @@ public void updateVmViewError(String scope, String message) { @Override public void refreshWatchList() { - // Clear debug controls - watchListModel.clear(); - - for (String watch : basicEditor.getWatches()) { + debugPresenter.refreshWatchList(); + } - watchListModel.addElement(watch + ": " + "???"); - } - watchListModel.addElement(" "); // Last line is blank, and can be clicked on to add new watch + @Override + public void onToggleBreakpoint(String filePath, int line) { + basicEditor.toggleBreakpt(filePath, line); } - private void editWatch() { - String newWatch, oldWatch; + private Component getActiveEditorHost() { + return splitTabControl.getTabCount() == 0 ? primaryTabHost : editorSplitPane; + } - // Find watch - int index = watchListBox.getSelectedIndex(); - int saveIndex = index; + private void setEditorContent(Component component) { + contentPane.setLeftComponent(component); + } - // Extract watch text - oldWatch = basicEditor.getWatchOrDefault(index); + private void configurePrimaryTabHost() { + addTabDropdownButton.setFocusable(false); + addTabDropdownButton.setToolTipText("Create a new tab or open an asset"); + addTabDropdownButton.putClientProperty("JButton.buttonType", "toolBarButton"); + addTabDropdownButton.putClientProperty( + "FlatLaf.style", "arc: 14; focusWidth: 0; innerFocusWidth: 0; margin: 6,6,6,6"); + addTabDropdownButton.setOpaque(false); + addTabDropdownButton.setMargin(new Insets(6, 6, 6, 6)); + addTabDropdownButton.setPreferredSize(TAB_HEADER_ICON_BUTTON_SIZE); + addTabDropdownButton.setMinimumSize(TAB_HEADER_ICON_BUTTON_SIZE); + addTabDropdownButton.setMaximumSize(TAB_HEADER_ICON_BUTTON_SIZE); + addTabDropdownButton.addActionListener(e -> showCreateTabMenu(addTabDropdownButton)); + tabControl.putClientProperty(TABBED_PANE_LEADING_COMPONENT, addTabDropdownButton); + ButtonGroup viewModeButtons = new ButtonGroup(); + viewModeButtons.add(editViewButton); + viewModeButtons.add(editPreviewViewButton); + viewModeButtons.add(previewViewButton); + fileViewModeTabs.add(editViewButton); + fileViewModeTabs.add(editPreviewViewButton); + fileViewModeTabs.add(previewViewButton); + fileViewModeTabsHost.setVisible(false); + tabControl.putClientProperty(TABBED_PANE_TRAILING_COMPONENT, fileViewModeTabsHost); + tabControl.addChangeListener(e -> onSelectedTabChanged()); + primaryTabHost.add(tabControl, BorderLayout.CENTER); + } - // Prompt for new text - newWatch = (String) JOptionPane.showInputDialog( - frame, "Enter variable/expression:", "Watch variable", JOptionPane.PLAIN_MESSAGE, null, null, oldWatch); + private JToggleButton createFileViewModeButton( + String tooltip, String iconPath, IFileViewer.ViewMode viewMode, String segmentPosition) { + JToggleButton button = new JToggleButton(createScaledIcon(iconPath, 18)); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "segmented"); + button.putClientProperty("JButton.segmentPosition", segmentPosition); + button.putClientProperty("FlatLaf.style", SEGMENTED_BUTTON_STYLE); + button.setOpaque(false); + button.setMargin(new Insets(6, 8, 6, 8)); + button.setPreferredSize(TAB_VIEW_MODE_BUTTON_SIZE); + button.setMinimumSize(TAB_VIEW_MODE_BUTTON_SIZE); + button.setMaximumSize(TAB_VIEW_MODE_BUTTON_SIZE); + button.addActionListener(e -> setSelectedFileViewMode(viewMode)); + return button; + } - basicEditor.updateWatch(newWatch, index); + private JPanel createSegmentedButtonStrip() { + JPanel panel = new RoundedCardPanel(RoundedCardPanel.DEFAULT_ARC); + panel.setLayout(new BoxLayout(panel, BoxLayout.LINE_AXIS)); + panel.setBackground(SEGMENTED_BACKGROUND); + panel.setBorder(new EmptyBorder(1, 1, 1, 1)); + return panel; + } - watchListBox.setSelectedIndex(saveIndex); - updateWatchHint(); + private JPanel createSegmentedButtonStripHost(JPanel strip) { + JPanel host = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0)); + host.setOpaque(false); + host.setBorder(new EmptyBorder(2, 4, 2, 0)); + host.add(strip); + return host; } - void deleteWatch() { + private void setSelectedFileViewMode(IFileViewer.ViewMode viewMode) { + IFileViewer viewer = getSelectedFileViewer(); + if (viewer == null || !viewer.hasPreview()) { + refreshFileViewModeButtons(); + return; + } + viewer.setViewMode(viewMode); + viewer.getContentPane().revalidate(); + viewer.getContentPane().repaint(); + refreshFileViewModeButtons(); + } - // Find watch - int index = watchListBox.getSelectedIndex(); - int saveIndex = index; + private void refreshFileViewModeButtons() { + IFileViewer viewer = getSelectedFileViewer(); + boolean hasPreview = viewer != null && viewer.hasPreview(); + fileViewModeTabsHost.setVisible(hasPreview); + if (!hasPreview) { + return; + } - // Delete watch - basicEditor.removeWatchAt(index); + IFileViewer.ViewMode viewMode = viewer.getViewMode(); + editViewButton.setSelected(viewMode == IFileViewer.ViewMode.EDITOR); + editPreviewViewButton.setSelected(viewMode == IFileViewer.ViewMode.EDITOR_AND_PREVIEW); + previewViewButton.setSelected( + viewMode == IFileViewer.ViewMode.PREVIEW || viewMode == IFileViewer.ViewMode.DEFAULT); + } - watchListBox.setSelectedIndex(saveIndex); - updateWatchHint(); + private IFileViewer getSelectedFileViewer() { + return getFileViewerAt(tabControl.getSelectedIndex()); } - private void updateWatchHint() { - int index = watchListBox.getSelectedIndex(); - if (index > -1 && index < basicEditor.getWatchListSize()) { - watchListBox.setToolTipText((String) watchListModel.get(index)); - } else { - watchListBox.setToolTipText(""); + private IFileViewer getFileViewerAt(int index) { + if (index < 0 || index >= tabControl.getTabCount()) { + return null; } + Component component = tabControl.getComponentAt(index); + if (component instanceof JComponent tabContent + && tabContent.getClientProperty(TAB_FILE_VIEWER_PROPERTY) instanceof IFileViewer viewer) { + return viewer; + } + return null; } - @Override - public void onToggleBreakpoint(String filePath, int line) { - basicEditor.toggleBreakpt(filePath, line); + private void configureSplitTabs() { + splitTabControl.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); + splitTabControl.putClientProperty(TABBED_PANE_TAB_CLOSABLE, true); + splitTabControl.putClientProperty( + TABBED_PANE_TAB_CLOSE_CALLBACK, (BiConsumer) (tabPane, tabIndex) -> { + if (tabIndex >= 0) { + splitTabControl.remove(tabIndex); + if (splitTabControl.getTabCount() == 0 + && basicEditor != null + && basicEditor.getMode() != ApMode.AP_CLOSED) { + setEditorContent(primaryTabHost); + } + } + }); } - private void setClosingTabsEnabled(boolean enabled) { - tabControl.putClientProperty(TABBED_PANE_TAB_CLOSABLE, enabled); - // TODO get main file index - // int main = 0; - // TODO only disable closing the tab with the main program - // if (enabled) { - // for (int i = 0; i < mTabControl.getTabCount(); i++) { - // ((ButtonTabComponent) - // mTabControl.getTabComponentAt(i)).getButton().setEnabled(true); - // } - // } else { - // if (main > -1 && main < mTabControl.getTabCount()) { - // ((ButtonTabComponent) - // mTabControl.getTabComponentAt(main)).getButton().setEnabled(false); + private void configureTabContextMenu() { + tabControl.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + maybeShowTabPopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybeShowTabPopup(e); + } + }); + } + + private void maybeShowTabPopup(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + int tabIndex = tabControl.indexAtLocation(e.getX(), e.getY()); + if (tabIndex < 0 || tabIndex >= fileManager.getFileEditors().size()) { + return; + } + + JPopupMenu popup = new JPopupMenu(); + FileEditor contextEditor = fileManager.getFileEditors().get(tabIndex); + JMenuItem setRunnable = new JMenuItem("Set as runnable file"); + setRunnable.addActionListener(x -> { + if (contextEditor == null) { + return; + } + runTargetFollowsCurrentTab = false; + fileManager.setRunnableFileEditor(contextEditor); + refreshRunnableFileControls(); + }); + setRunnable.setEnabled(contextEditor != null); + popup.add(setRunnable); + + JMenuItem splitPreview = new JMenuItem("Split right"); + splitPreview.addActionListener(x -> openSplitPreview(tabIndex)); + popup.add(splitPreview); + + JMenuItem popOut = new JMenuItem("Pop out tab"); + popOut.addActionListener(x -> popOutTab(tabIndex)); + popup.add(popOut); + + popup.show(tabControl, e.getX(), e.getY()); + } + + private void openSplitPreview(int tabIndex) { + if (tabIndex < 0 || tabIndex >= fileManager.getFileEditors().size()) { + return; + } + FileEditor editor = fileManager.getFileEditors().get(tabIndex); + if (editor == null) { + return; + } + File source = editor.getFile(); + if (source == null) { + return; + } + + FileEditor preview = FileEditor.open(source, this, fileManager, this, linkGenerator, searchContext); + preview.getEditorPane().setEditable(false); + splitTabControl.addTab(preview.getTitle() + " (split)", preview.getContentPane()); + splitTabControl.setSelectedIndex(splitTabControl.getTabCount() - 1); + setEditorContent(getActiveEditorHost()); + SwingUtilities.invokeLater(() -> editorSplitPane.setDividerLocation(0.68)); + } + + private void popOutTab(int tabIndex) { + if (tabIndex < 0 || tabIndex >= fileManager.getFileEditors().size()) { + return; + } + FileEditor editor = fileManager.getFileEditors().get(tabIndex); + if (editor == null) { + return; + } + File source = editor.getFile(); + if (source == null) { + return; + } + + FileEditor preview = FileEditor.open(source, this, fileManager, this, linkGenerator, searchContext); + preview.getEditorPane().setEditable(false); + + JFrame popout = new JFrame("Pop out: " + preview.getShortFilename()); + popout.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + popout.setLayout(new BorderLayout()); + popout.add(preview.getContentPane(), BorderLayout.CENTER); + popout.setSize(new Dimension(720, 480)); + popout.setLocationRelativeTo(frame); + popout.setVisible(true); + } + + private void showCreateTabMenu(Component anchor) { + JPopupMenu popup = new JPopupMenu(); + + JMenuItem newTabItem = new JMenuItem("New Program Tab"); + newTabItem.addActionListener(e -> addTab()); + popup.add(newTabItem); + + JMenuItem openAssetItem = new JMenuItem("Open Asset or Docs File..."); + openAssetItem.addActionListener(e -> actionOpenAsset()); + popup.add(openAssetItem); + + popup.show(anchor, 0, anchor.getHeight()); + } + + private void actionOpenAsset() { + JFileChooser chooser = new JFileChooser(); + chooser.setCurrentDirectory(new File(fileManager.getCurrentDirectory())); + int result = chooser.showOpenDialog(frame); + if (result != JFileChooser.APPROVE_OPTION) { + return; + } + + File selected = chooser.getSelectedFile(); + + openFileWithPreferredViewer(selected); + } + + private void configureLeftSidebar() { + leftSidebarRail.setFloatable(false); + leftSidebarRail.setRollover(true); + bottomBarRail.setFloatable(false); + bottomBarRail.setRollover(true); + + if (LEFT_SIDEBAR_ENABLED) { + Arrays.stream(panels) + .filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) + .forEach(x -> { + leftSidebarContent.add(x.build(this.basicEditor), x.id()); + addLeftSidebarButton( + x.id(), + createImageIcon(x.getActiveIconPath(), x.getActiveIconTint()), + createImageIcon(x.getInactiveIconPath()), + x.getTitle()); + }); + } + + Arrays.stream(panels) + .filter(x -> x.getLayoutConstraints() == EditorLayout.SOUTH) + .forEach(x -> { + bottomBarContent.add(x.build(this.basicEditor), x.id()); + addBottomBarButton( + x.id(), + createImageIcon(x.getActiveIconPath(), x.getActiveIconTint()), + createImageIcon(x.getInactiveIconPath()), + x.getTitle()); + }); + + bottomBarContainer.add(bottomBarContent, BorderLayout.CENTER); + + if (LEFT_SIDEBAR_ENABLED) { + // Select first panel if available + Arrays.stream(panels) + .filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) + .findFirst() + .ifPresent(x -> { + selectLeftSidebarSection(x.id(), true); + }); + } + } + + private void configureRightSidebar() { + rightDocsRail.setFloatable(false); + rightDocsRail.setRollover(true); + rightDocsContainer.add(rightDocsContent, BorderLayout.CENTER); + Arrays.stream(panels) + .filter(x -> x.getLayoutConstraints() == EditorLayout.EAST) + .forEach(x -> { + addRightDocsButton( + x.id(), + createImageIcon(x.getActiveIconPath(), x.getActiveIconTint()), + createImageIcon(x.getInactiveIconPath()), + x.getTitle()); + JComponent content = x.build(this.basicEditor); + if (content != null) { + rightDocsContent.add(content, x.id()); + } + }); + + Arrays.stream(panels) + .filter(x -> x.getLayoutConstraints() == EditorLayout.EAST) + .findFirst() + .ifPresent(x -> selectRightDocsSection(x.id())); + } + + private void addLeftSidebarButton(String key, Icon selectedIcon, Icon icon, String tooltip) { + JToggleButton button = createRailButton(selectedIcon, icon, tooltip); + button.addActionListener(e -> onLeftSidebarButtonPressed(key)); + leftSidebarGroup.add(button); + leftSidebarButtons.put(key, button); + leftSidebarRail.add(button); + } + + private void addRightDocsButton(String key, Icon selectedIcon, Icon icon, String tooltip) { + JToggleButton button = createRailButton(selectedIcon, icon, tooltip); + button.addActionListener(e -> onRightDocsButtonPressed(key)); + rightDocsGroup.add(button); + rightDocsButtons.put(key, button); + rightDocsRail.add(button); + } + + private void addBottomBarButton(String key, Icon selectedIcon, Icon icon, String tooltip) { + JToggleButton button = createRailButton(selectedIcon, icon, tooltip); + button.addActionListener(e -> onBottomBarButtonPressed(key)); + bottomBarGroup.add(button); + bottomBarButtons.put(key, button); + bottomBarRail.add(button); + } + + private JToggleButton createRailButton(Icon selectedIcon, Icon icon, String tooltip) { + JToggleButton button = new JToggleButton(icon); + button.setSelectedIcon(selectedIcon != null ? selectedIcon : icon); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.setMargin(new Insets(8, 8, 8, 8)); + button.setMaximumSize(new Dimension(38, 38)); + button.setPreferredSize(new Dimension(38, 38)); + return button; + } + + private void onLeftSidebarButtonPressed(String key) { + if (Objects.equals(activeLeftSidebarKey, key) && isLeftSidebarExpanded()) { + collapseLeftSidebar(); + return; + } + selectLeftSidebarSection(key, true); + } + + private void onBottomBarButtonPressed(String key) { + if (Objects.equals(activeBottomBarKey, key) && isBottomBarExpanded()) { + collapseBottomBar(); + return; + } + selectBottomBarSection(key, true); + } + + private void selectLeftSidebarSection(String key, boolean ensureExpanded) { + CardLayout layout = (CardLayout) leftSidebarContent.getLayout(); + layout.show(leftSidebarContent, key); + activeLeftSidebarKey = key; + + JToggleButton button = leftSidebarButtons.get(key); + if (button != null) { + button.setSelected(true); + } + + if (ensureExpanded) { + expandLeftSidebar(); + } + } + + private void selectBottomBarSection(String key, boolean ensureExpanded) { + if (!bottomBarButtons.containsKey(key)) { + return; + } + activeBottomBarKey = key; + CardLayout layout = (CardLayout) bottomBarContent.getLayout(); + layout.show(bottomBarContent, key); + JToggleButton button = bottomBarButtons.get(key); + if (button != null) { + button.setSelected(true); + } + + if (ensureExpanded) { + expandBottomBar(); + } + syncDebugMenuSelection(); + } + + private boolean isLeftSidebarExpanded() { + return !leftSidebarCollapsed && workspacePane.getLeftComponent() == leftSidebarContent; + } + + private void collapseLeftSidebar() { + if (workspacePane.getLeftComponent() == leftSidebarContent) { + int currentWidth = leftSidebarContent.getWidth(); + if (currentWidth <= 12) { + currentWidth = workspacePane.getDividerLocation(); + } + if (currentWidth > 12) { + expandedLeftSidebarWidth = currentWidth; + } + } + + leftSidebarCollapsed = true; + activeLeftSidebarKey = null; + leftSidebarGroup.clearSelection(); + + workspacePane.setResizeWeight(0.0); + workspacePane.setDividerSize(0); + workspacePane.setLeftComponent(null); + workspacePane.revalidate(); + workspacePane.repaint(); + } + + private void expandLeftSidebar() { + leftSidebarCollapsed = false; + if (workspacePane.getLeftComponent() != leftSidebarContent) { + workspacePane.setLeftComponent(leftSidebarContent); + } + workspacePane.setDividerSize(workspacePaneDividerSize); + workspacePane.setResizeWeight(0.18); + workspacePane.revalidate(); + + int target = Math.max(expandedLeftSidebarWidth, 180); + SwingUtilities.invokeLater(() -> { + if (!leftSidebarCollapsed && workspacePane.getLeftComponent() == leftSidebarContent) { + setDividerLocationClamped(workspacePane, target); + } + }); + } + + private boolean isBottomBarExpanded() { + return !bottomBarCollapsed && mainPane.getBottomComponent() == bottomBarContainer; + } + + private void collapseBottomBar() { + if (mainPane.getBottomComponent() == bottomBarContainer) { + int currentHeight = bottomBarContainer.getHeight(); + if (currentHeight <= 12 && mainPane.getHeight() > 0) { + currentHeight = mainPane.getHeight() - mainPane.getDividerLocation() - mainPane.getDividerSize(); + } + if (currentHeight > 12) { + expandedBottomBarHeight = Math.max(120, currentHeight); + } + } + + bottomBarCollapsed = true; + activeBottomBarKey = null; + bottomBarGroup.clearSelection(); + + mainPane.setResizeWeight(1.0); + mainPane.setDividerSize(0); + mainPane.setBottomComponent(null); + mainPane.revalidate(); + mainPane.repaint(); + syncDebugMenuSelection(); + } + + private void expandBottomBar() { + bottomBarCollapsed = false; + if (mainPane.getBottomComponent() != bottomBarContainer) { + mainPane.setBottomComponent(bottomBarContainer); + } + mainPane.setDividerSize(mainPaneDividerSize); + mainPane.setResizeWeight(1.0); + mainPane.revalidate(); + + int targetBottomHeight = Math.max(expandedBottomBarHeight, 140); + SwingUtilities.invokeLater(() -> { + if (!bottomBarCollapsed + && mainPane.getBottomComponent() == bottomBarContainer + && mainPane.getHeight() > 0) { + int newDivider = mainPane.getHeight() - targetBottomHeight - mainPane.getDividerSize(); + setDividerLocationClamped(mainPane, newDivider); + } + }); + syncDebugMenuSelection(); + } + + private void syncDebugMenuSelection() { + debugMenuItem.setSelected(isBottomBarExpanded() && Objects.equals(activeBottomBarKey, "debug")); + } + + private void onRightDocsButtonPressed(String key) { + if (Objects.equals(activeRightDocsKey, key) && isRightDocsExpanded()) { + collapseRightDocs(); + return; + } + selectRightDocsSection(key); + } + + private void selectRightDocsSection(String key) { + if (!rightDocsButtons.containsKey(key)) { + return; + } + activeRightDocsKey = key; + CardLayout layout = (CardLayout) rightDocsContent.getLayout(); + layout.show(rightDocsContent, key); + + JToggleButton button = rightDocsButtons.get(key); + if (button != null) { + button.setSelected(true); + } + + expandRightDocs(); + } + + private boolean isRightDocsExpanded() { + return !rightDocsCollapsed && contentPane.getRightComponent() == rightDocsContainer; + } + + private void collapseRightDocs() { + if (contentPane.getRightComponent() == rightDocsContainer) { + int currentWidth = rightDocsContainer.getWidth(); + if (currentWidth <= 12 && contentPane.getWidth() > 0) { + currentWidth = contentPane.getWidth() - contentPane.getDividerLocation() - contentPane.getDividerSize(); + } + if (currentWidth > 12) { + expandedRightDocsWidth = currentWidth; + } + } + + rightDocsCollapsed = true; + activeRightDocsKey = null; + rightDocsGroup.clearSelection(); + + contentPane.setResizeWeight(1.0); + contentPane.setDividerSize(0); + contentPane.setRightComponent(null); + contentPane.revalidate(); + contentPane.repaint(); + } + + private void expandRightDocs() { + rightDocsCollapsed = false; + if (contentPane.getRightComponent() != rightDocsContainer) { + contentPane.setRightComponent(rightDocsContainer); + } + contentPane.setDividerSize(contentPaneDividerSize); + contentPane.setResizeWeight(0.74); + contentPane.revalidate(); + + int targetDocsWidth = Math.max(expandedRightDocsWidth, 220); + SwingUtilities.invokeLater(() -> { + if (!rightDocsCollapsed + && contentPane.getRightComponent() == rightDocsContainer + && contentPane.getWidth() > 0) { + int newDivider = contentPane.getWidth() - targetDocsWidth - contentPane.getDividerSize(); + setDividerLocationClamped(contentPane, newDivider); + } + }); + } + + private void setDividerLocationClamped(JSplitPane splitPane, int requestedLocation) { + int minimum = splitPane.getMinimumDividerLocation(); + int maximum = splitPane.getMaximumDividerLocation(); + if (maximum < minimum) { + splitPane.setDividerLocation(requestedLocation); + return; + } + splitPane.setDividerLocation(Math.max(minimum, Math.min(requestedLocation, maximum))); + } + + private void refreshSidebarContent() { + for (IEditorPanelProvider panel : panels) { + panel.refresh(this.basicEditor.getBasic4gl()); + } + } + + private void refreshRunnableFileControls() { + if (fileManager == null) { + return; + } + + FileEditor currentEditor = getCurrentTextEditor(); + if (runTargetFollowsCurrentTab && currentEditor != null) { + updateRunnableFileToCurrentTab(currentEditor); + } + + runTargetButton.setText(getRunTargetButtonLabel(currentEditor)); + runTargetButton.setEnabled(currentEditor != null || hasTextEditors()); + rebuildRunTargetPopup(currentEditor); + } + + private boolean hasTextEditors() { + for (int i = 0; i < fileManager.getFileEditors().size(); i++) { + if (fileManager.getFileEditors().get(i) != null) { + return true; + } + } + return false; + } + + private String getRunTargetButtonLabel(FileEditor currentEditor) { + if (runTargetFollowsCurrentTab) { + return currentRunTargetLabel(currentEditor); + } + + FileEditor runnableEditor = getTextEditorAt(fileManager.getRunnableFileIndex()); + if (runnableEditor != null) { + return runnableEditor.getShortFilename(); + } + + return currentRunTargetLabel(currentEditor); + } + + private String currentRunTargetLabel(FileEditor currentEditor) { + if (currentEditor == null) { + return ""; + } + String name = currentEditor.getShortFilename(); + return "Current Program [" + name + "]"; + } + + private void showRunTargetPopup() { + refreshRunnableFileControls(); + runTargetPopup.show(runTargetButton, 0, runTargetButton.getHeight()); + } + + private void rebuildRunTargetPopup(FileEditor currentEditor) { + runTargetPopup.removeAll(); + ButtonGroup runTargetGroup = new ButtonGroup(); + + JRadioButtonMenuItem currentItem = new JRadioButtonMenuItem(currentRunTargetLabel(currentEditor)); + currentItem.setSelected(runTargetFollowsCurrentTab); + currentItem.addActionListener(e -> { + runTargetFollowsCurrentTab = true; + updateRunnableFileToCurrentTab(getCurrentTextEditor()); + refreshRunnableFileControls(); + }); + runTargetGroup.add(currentItem); + runTargetPopup.add(currentItem); + + if (hasTextEditors()) { + runTargetPopup.addSeparator(); + } + + FileEditor runnableEditor = getTextEditorAt(fileManager.getRunnableFileIndex()); + for (int i = 0; i < fileManager.getFileEditors().size(); i++) { + FileEditor editor = fileManager.getFileEditors().get(i); + if (editor == null) { + continue; + } + + JRadioButtonMenuItem item = new JRadioButtonMenuItem(editor.getShortFilename()); + item.setSelected(!runTargetFollowsCurrentTab && editor == runnableEditor); + item.addActionListener(e -> { + runTargetFollowsCurrentTab = false; + fileManager.setRunnableFileEditor(editor); + refreshRunnableFileControls(); + }); + runTargetGroup.add(item); + runTargetPopup.add(item); + } + } + + private void onSelectedTabChanged() { + refreshFileViewModeButtons(); + if (runTargetFollowsCurrentTab) { + refreshRunnableFileControls(); + } else { + rebuildRunTargetPopup(getCurrentTextEditor()); + } + } + + private FileEditor getCurrentTextEditor() { + return getTextEditorAt(tabControl.getSelectedIndex()); + } + + private FileEditor getTextEditorAt(int index) { + if (fileManager == null || index < 0 || index >= fileManager.getFileEditors().size()) { + return null; + } + return fileManager.getFileEditors().get(index); + } + + private void updateRunnableFileToCurrentTab(FileEditor currentEditor) { + if (currentEditor != null) { + fileManager.setRunnableFileEditor(currentEditor); + } else { + fileManager.ensureRunnableFileValid(); + } + } + + // ------------------------------------------------------------------------- + // Symbol indexer support + // ------------------------------------------------------------------------- + + /** + * Concatenates the text of every open editor tab into a single string, separated by newlines. + * This gives the {@link SymbolIndexer} full visibility of all open files for the debounced + * background scan. + */ + public String collectAllSourceText() { + if (fileManager == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (FileEditor fe : fileManager.getFileEditors()) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(fe.getEditorPane().getText()); + } + return sb.toString(); + } + + @Override + public void selectNextBookmark() { + fileManager.selectNextBookmark(tabControl.getSelectedIndex()); + } + + @Override + public void selectPreviousBookmark() { + fileManager.selectPreviousBookmark(tabControl.getSelectedIndex()); + } + + @Override + public void toggleBookmark() { + fileManager.toggleBookmark(tabControl.getSelectedIndex()); + } + + @Override + public List listBookmarks() { + List bookmarks = new ArrayList<>(); + for (FileEditor editor : fileManager.getFileEditors()) { + if (editor == null) { + continue; + } + String filePath = editor.getFilePath(); + String fileName = editor.getShortFilename(); + for (FileEditor.BookmarkLine bookmark : editor.getBookmarks()) { + bookmarks.add(new BookmarkInfo(filePath, fileName, bookmark.lineNumber(), bookmark.lineText())); + } + } + bookmarks.sort(Comparator.comparing(BookmarkInfo::fileName, String.CASE_INSENSITIVE_ORDER) + .thenComparingInt(BookmarkInfo::lineNumber)); + return bookmarks; + } + + @Override + public void goToBookmark(String filePath, int lineNumber) { + int index = getTabIndex(filePath); + if (index == -1 && filePath != null && !filePath.isBlank()) { + File file = new File(filePath); + if (file.exists()) { + addTab(FileEditor.open(file, this, fileManager, this, linkGenerator, searchContext)); + index = getTabIndex(filePath); + } + } + if (index < 0 || index >= fileManager.getFileEditors().size()) { + return; + } + tabControl.setSelectedIndex(index); + FileEditor editor = fileManager.getFileEditors().get(index); + if (editor != null) { + editor.goToLine(lineNumber); + } + } + + private int findOpenTabIndexByPath(String absolutePath) { + if (absolutePath == null || absolutePath.isBlank()) { + return -1; + } + for (int i = 0; i < tabControl.getTabCount(); i++) { + IFileViewer viewer = getFileViewerAt(i); + if (viewer == null || viewer.getFilePath() == null) { + continue; + } + if (absolutePath.equals(viewer.getFilePath())) { + return i; + } + } + return fileManager.getTabIndex(absolutePath); + } + + public void setWorkspaceDirectory(File folder) { + if (folder == null) { + return; + } + File absoluteFolder = folder.getAbsoluteFile(); + if (!absoluteFolder.exists() || !absoluteFolder.isDirectory()) { + JOptionPane.showMessageDialog(frame, "Folder not found: " + absoluteFolder.getAbsolutePath()); + return; + } + fileManager.setCurrentDirectory(absoluteFolder.getAbsolutePath()); + registerWorkspace(absoluteFolder); + refreshSidebarContent(); + } + + @Override + public void insertText(String text, int caretOffset) { + + int selectedTab = tabControl.getSelectedIndex(); + if (selectedTab < 0 || selectedTab >= fileManager.getFileEditors().size()) { + return; + } + JTextArea editorPane = fileManager.getFileEditors().get(selectedTab).getEditorPane(); + int insertStart = editorPane.getSelectionStart(); + editorPane.replaceSelection(text); + editorPane.setCaretPosition( + Math.min(insertStart + caretOffset, editorPane.getDocument().getLength())); + editorPane.requestFocusInWindow(); + } + + private void registerWorkspace(File folder) { + if (folder == null) { + return; + } + File absolute = folder.getAbsoluteFile(); + if (!absolute.exists() || !absolute.isDirectory()) { + return; + } + recentWorkspaces.removeIf(existing -> existing == null + || !existing.exists() + || existing.getAbsoluteFile().equals(absolute)); + recentWorkspaces.add(0, absolute); + while (recentWorkspaces.size() > MAX_RECENT_WORKSPACES) { + recentWorkspaces.remove(recentWorkspaces.size() - 1); + } + saveRecentWorkspaces(); + setRecentItems(basicEditor.getRecentFiles()); + refreshEmptyStateRecentItems(); + } + + private void loadRecentWorkspaces() { + recentWorkspaces.clear(); + File config = new File(applicationStoragePath, RECENT_WORKSPACES_FILE); + if (!config.exists()) { + return; + } + Properties properties = new Properties(); + try (FileInputStream stream = new FileInputStream(config)) { + properties.load(stream); + String csv = properties.getProperty(RECENT_WORKSPACES_KEY, ""); + for (String entry : csv.split(",")) { + if (entry == null || entry.isBlank()) { + continue; + } + File folder = new File(entry.trim()).getAbsoluteFile(); + if (folder.exists() && folder.isDirectory()) { + recentWorkspaces.add(folder); + } + if (recentWorkspaces.size() >= MAX_RECENT_WORKSPACES) { + break; + } + } + } catch (IOException ignored) { + // Ignore workspace history load errors to avoid interrupting startup. + } + } + + private void saveRecentWorkspaces() { + File config = new File(applicationStoragePath, RECENT_WORKSPACES_FILE); + Properties properties = new Properties(); + String csv = recentWorkspaces.stream() + .filter(Objects::nonNull) + .map(File::getAbsolutePath) + .distinct() + .limit(MAX_RECENT_WORKSPACES) + .reduce((a, b) -> a + "," + b) + .orElse(""); + properties.setProperty(RECENT_WORKSPACES_KEY, csv); + try { + File parent = config.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + try (FileOutputStream out = new FileOutputStream(config)) { + properties.store(out, "Recent workspaces"); + } + } catch (IOException ignored) { + // Ignore workspace history save errors. + } + } + + private void refreshEmptyStateRecentItems() { + if (!(emptyTabPanel instanceof EmptyTabPanel) || basicEditor == null) { + return; + } + if (basicEditor.getMode() != ApMode.AP_CLOSED) { + return; + } + emptyTabPanel = new EmptyTabPanel( + this, + newMenuItem.getAccelerator(), + openMenuItem.getAccelerator(), + openFolderMenuItem.getAccelerator(), + basicEditor.getRecentFiles(), + recentWorkspaces); + setEditorContent(emptyTabPanel); + } + + private void setClosingTabsEnabled(boolean enabled) { + tabControl.putClientProperty(TABBED_PANE_TAB_CLOSABLE, enabled); + // TODO get main file index + // int main = 0; + // TODO only disable closing the tab with the main program + // if (enabled) { + // for (int i = 0; i < mTabControl.getTabCount(); i++) { + // ((ButtonTabComponent) + // mTabControl.getTabComponentAt(i)).getButton().setEnabled(true); + // } + // } else { + // if (main > -1 && main < mTabControl.getTabCount()) { + // ((ButtonTabComponent) + // mTabControl.getTabComponentAt(main)).getButton().setEnabled(false); // } // } } @@ -1741,6 +3040,13 @@ public void onSearchResult(String message) { setCompilerStatus(message); } + @Override + public void onBookmarksChanged(String filePath) { + for (IEditorPanelProvider panel : panels) { + panel.onFileModified(filePath); + } + } + @Override public void onNewClick() { actionNew(); @@ -1753,12 +3059,24 @@ public void onOpenClick() { @Override public void onOpenClick(File file) { - actionOpen(file); + openFileWithPreferredViewer(file); + } + + @Override + public void onOpenFolderClick() { + actionOpenFolder(); + } + + @Override + public void onOpenWorkspaceClick(File folder) { + setWorkspaceDirectory(folder); } @Override public void onCurrentDirectoryChanged(String directory) { basicEditor.onCurrentDirectoryChanged(directory); + // TODO move into editor + refreshSidebarContent(); } @Override diff --git a/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java b/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java index b686b953..401f528d 100644 --- a/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java +++ b/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java @@ -1,5 +1,7 @@ package com.basic4gl.desktop; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; + import com.basic4gl.app.desktop.config.IConfigurableAppSettings; import com.basic4gl.desktop.spi.Builder; import com.basic4gl.desktop.spi.Configuration; @@ -122,6 +124,7 @@ public ProjectSettingsDialog( splitPane.setDividerLocation(160); splitPane.setResizeWeight(0); splitPane.setBorder(null); + hideSplitPaneHandle(splitPane); contentPane.add(splitPane, BorderLayout.CENTER); sectionsList.addListSelectionListener(e -> { diff --git a/app/src/main/java/com/basic4gl/desktop/content/AssetService.java b/app/src/main/java/com/basic4gl/desktop/content/AssetService.java new file mode 100644 index 00000000..6b4ece80 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/AssetService.java @@ -0,0 +1,9 @@ +package com.basic4gl.desktop.content; + +public class AssetService { + private final FileManager fileManager; + + public AssetService(FileManager fileManager) { + this.fileManager = fileManager; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/AudioFileViewer.java b/app/src/main/java/com/basic4gl/desktop/content/AudioFileViewer.java new file mode 100644 index 00000000..2a5c45f3 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/AudioFileViewer.java @@ -0,0 +1,179 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.editor.IFileViewer; +import java.awt.*; +import java.io.File; +import javax.swing.*; + +/** + * Audio file viewer for playing WAV, OGG, MP3, FLAC, and other audio formats. + * Provides a player interface with: + * - Play/Pause controls + * - Progress bar + * - Volume control + * - File information display + * + * Note: Audio playback implementation depends on the audio library availability. + * Currently provides a UI framework that can be extended with actual playback functionality. + */ +public class AudioFileViewer implements IFileViewer { + private final File file; + private final JPanel contentPanel; + + public AudioFileViewer(File file) { + this.file = file; + this.contentPanel = createAudioPlayerUI(); + } + + private JPanel createAudioPlayerUI() { + JPanel mainPanel = new JPanel(new BorderLayout()); + mainPanel.setBackground(Color.WHITE); + mainPanel.setBorder(BorderFactory.createEmptyBorder(20, 20, 20, 20)); + + // Player controls + JPanel controlsPanel = createControlsPanel(); + mainPanel.add(controlsPanel, BorderLayout.CENTER); + + return mainPanel; + } + + private JPanel createControlsPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setAlignmentX(Component.CENTER_ALIGNMENT); + + // File info + if (file != null && file.exists()) { + JLabel fileNameLabel = new JLabel("File: " + file.getName()); + fileNameLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + fileNameLabel.setFont(fileNameLabel.getFont().deriveFont(Font.BOLD, 14f)); + panel.add(fileNameLabel); + + JLabel fileSizeLabel = new JLabel(String.format("Size: %.2f MB", file.length() / (1024.0 * 1024.0))); + fileSizeLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + fileSizeLabel.setFont(fileSizeLabel.getFont().deriveFont(12f)); + panel.add(fileSizeLabel); + + panel.add(Box.createVerticalStrut(20)); + } + + // Control buttons + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.CENTER, 10, 0)); + JButton playButton = new JButton("▶ Play"); + JButton pauseButton = new JButton("⏸ Pause"); + JButton stopButton = new JButton("⏹ Stop"); + + playButton.addActionListener(e -> JOptionPane.showMessageDialog( + contentPanel, + "Audio playback: Please implement audio playback support", + "Feature Not Yet Implemented", + JOptionPane.INFORMATION_MESSAGE)); + pauseButton.setEnabled(false); + stopButton.setEnabled(false); + + buttonPanel.add(playButton); + buttonPanel.add(pauseButton); + buttonPanel.add(stopButton); + panel.add(buttonPanel); + + // Progress bar + panel.add(Box.createVerticalStrut(20)); + JLabel progressLabel = new JLabel("Time: 0:00 / 0:00"); + progressLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + panel.add(progressLabel); + + JProgressBar progressBar = new JProgressBar(0, 100); + progressBar.setValue(0); + progressBar.setAlignmentX(Component.LEFT_ALIGNMENT); + progressBar.setMaximumSize(new Dimension(Integer.MAX_VALUE, progressBar.getPreferredSize().height)); + panel.add(progressBar); + + // Volume control + panel.add(Box.createVerticalStrut(20)); + JPanel volumePanel = new JPanel(); + volumePanel.setLayout(new BoxLayout(volumePanel, BoxLayout.X_AXIS)); + JLabel volumeLabel = new JLabel("Volume: "); + JSlider volumeSlider = new JSlider(0, 100, 80); + volumeSlider.setMaximumSize(new Dimension(300, volumeSlider.getPreferredSize().height)); + volumePanel.add(volumeLabel); + volumePanel.add(volumeSlider); + panel.add(volumePanel); + + // Info panel + panel.add(Box.createVerticalStrut(20)); + JPanel infoPanel = new JPanel(); + infoPanel.setLayout(new BoxLayout(infoPanel, BoxLayout.Y_AXIS)); + infoPanel.setBorder(BorderFactory.createTitledBorder("Info")); + JTextArea infoArea = new JTextArea("Audio player interface ready.\n\n" + "To enable playback, integrate with:\n" + + "- Paulscode SoundSystem (available in this project)\n" + + "- JavaFX MediaPlayer\n" + + "- Tritonus (Java Sound alternative)\n\n" + + "For now, you can open this file with a system audio player\n" + + "using the 'Open With' context menu."); + infoArea.setEditable(false); + infoArea.setLineWrap(true); + infoArea.setWrapStyleWord(true); + infoArea.setMargin(new Insets(10, 10, 10, 10)); + infoPanel.add(new JScrollPane(infoArea)); + panel.add(infoPanel); + + return panel; + } + + @Override + public String getTitle() { + if (file == null) { + return "[Audio]"; + } + return file.getName(); + } + + @Override + public String getFilePath() { + return file != null ? file.getAbsolutePath() : ""; + } + + @Override + public JComponent getContentPane() { + return contentPanel; + } + + @Override + public File getFile() { + return file; + } + + @Override + public String getShortFilename() { + return file != null ? file.getName() : "[Audio]"; + } + + @Override + public boolean isModified() { + // Audio files are read-only + return false; + } + + @Override + public void setModified() { + // Audio files are read-only + } + + @Override + public ViewerType getViewerType() { + return ViewerType.AUDIO_VIEWER; + } + + @Override + public boolean hasPreview() { + return false; + } + + @Override + public void setViewMode(ViewMode viewMode) {} + + @Override + public ViewMode getViewMode() { + return ViewMode.DEFAULT; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/ContentDocumentViewer.java b/app/src/main/java/com/basic4gl/desktop/content/ContentDocumentViewer.java new file mode 100644 index 00000000..2511c60c --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/ContentDocumentViewer.java @@ -0,0 +1,257 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.content.render.ContentNavigationHandler; +import com.basic4gl.desktop.content.render.ContentRenderRequest; +import com.basic4gl.desktop.content.render.ContentRenderer; +import com.basic4gl.desktop.content.render.HtmlContentRenderer; +import com.basic4gl.desktop.content.render.MarkdownContentRenderer; +import com.basic4gl.desktop.content.render.PlainTextContentRenderer; +import com.basic4gl.desktop.content.render.UnsupportedContentRenderer; +import com.basic4gl.desktop.editor.IFileViewer; +import com.basic4gl.desktop.spi.content.ContentDocument; +import com.basic4gl.desktop.spi.content.ContentPaths; +import java.awt.BorderLayout; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Locale; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; + +public final class ContentDocumentViewer implements IFileViewer { + + private final String contentId; + private final String title; + private final ContentDocument rootDocument; + private final Path materializedRoot; + private final List renderers; + private final JPanel contentPane = new JPanel(new BorderLayout()); + private final JPanel documentHost = new JPanel(new BorderLayout()); + private final JButton backButton = new JButton("Back"); + private final JButton forwardButton = new JButton("Forward"); + private final JButton homeButton = new JButton("Home"); + private final JButton keepOpenButton = new JButton("Keep Open"); + private final ArrayDeque backStack = new ArrayDeque<>(); + private final ArrayDeque forwardStack = new ArrayDeque<>(); + + private String currentPath; + private boolean pinned; + + public ContentDocumentViewer(String contentId, String title, ContentDocument document, Path materializedRoot) + throws IOException { + this( + contentId, + title, + document, + materializedRoot, + List.of( + new MarkdownContentRenderer(), + new HtmlContentRenderer(), + new PlainTextContentRenderer(), + new UnsupportedContentRenderer())); + } + + public ContentDocumentViewer( + String contentId, + String title, + ContentDocument document, + Path materializedRoot, + List renderers) + throws IOException { + this.contentId = contentId == null || contentId.isBlank() ? "content" : contentId; + this.title = title == null || title.isBlank() ? "Documentation" : title; + this.rootDocument = document; + this.materializedRoot = materializedRoot; + this.renderers = List.copyOf(renderers); + this.currentPath = ContentPaths.normalize(document.entryPath()); + buildChrome(); + renderCurrent(); + } + + public void navigateTo(String normalizedPath) { + String nextPath = ContentPaths.normalize(normalizedPath); + if (nextPath.equals(currentPath)) { + return; + } + backStack.push(currentPath); + forwardStack.clear(); + currentPath = nextPath; + renderOrShowError(); + } + + public void goBack() { + if (backStack.isEmpty()) { + return; + } + forwardStack.push(currentPath); + currentPath = backStack.pop(); + renderOrShowError(); + } + + public void goForward() { + if (forwardStack.isEmpty()) { + return; + } + backStack.push(currentPath); + currentPath = forwardStack.pop(); + renderOrShowError(); + } + + public void goHome() { + navigateTo(rootDocument.entryPath()); + } + + public boolean isPinned() { + return pinned; + } + + public void keepOpen() { + pinned = true; + keepOpenButton.setEnabled(false); + } + + public String getCurrentPath() { + return currentPath; + } + + @Override + public String getTitle() { + return title; + } + + @Override + public String getFilePath() { + return "content:" + contentId + "#" + currentPath; + } + + @Override + public JComponent getContentPane() { + return contentPane; + } + + @Override + public File getFile() { + return null; + } + + @Override + public String getShortFilename() { + return title; + } + + @Override + public boolean isModified() { + return false; + } + + @Override + public void setModified() {} + + @Override + public ViewerType getViewerType() { + return ViewerType.DOCUMENTATION_VIEWER; + } + + @Override + public boolean hasPreview() { + return false; + } + + @Override + public void setViewMode(ViewMode viewMode) {} + + @Override + public ViewMode getViewMode() { + return ViewMode.PREVIEW; + } + + private void buildChrome() { + JPanel toolbar = new JPanel(); + toolbar.add(backButton); + toolbar.add(forwardButton); + toolbar.add(homeButton); + toolbar.add(keepOpenButton); + toolbar.add(new JLabel(title)); + + backButton.addActionListener(e -> goBack()); + forwardButton.addActionListener(e -> goForward()); + homeButton.addActionListener(e -> goHome()); + keepOpenButton.addActionListener(e -> keepOpen()); + + contentPane.add(toolbar, BorderLayout.NORTH); + contentPane.add(documentHost, BorderLayout.CENTER); + refreshButtons(); + } + + private void renderOrShowError() { + try { + renderCurrent(); + } catch (IOException | RuntimeException ex) { + showError(ex); + } + } + + private void renderCurrent() throws IOException { + ContentDocument currentDocument = + new ContentDocument(mediaTypeForPath(currentPath), rootDocument.source(), currentPath); + ContentRenderer renderer = rendererFor(currentDocument.mediaType()); + JComponent component = renderer.render(new ContentRenderRequest( + currentDocument, materializedRoot, currentPath, new ContentNavigationHandler() { + @Override + public void navigateTo(String normalizedPath) { + ContentDocumentViewer.this.navigateTo(normalizedPath); + } + + @Override + public void openExternal(URI uri) {} + })); + documentHost.removeAll(); + documentHost.add(component, BorderLayout.CENTER); + refreshButtons(); + documentHost.revalidate(); + documentHost.repaint(); + } + + private ContentRenderer rendererFor(String mediaType) { + return renderers.stream() + .filter(renderer -> renderer.supports(mediaType)) + .findFirst() + .orElseGet(UnsupportedContentRenderer::new); + } + + private String mediaTypeForPath(String path) { + String lower = path.toLowerCase(Locale.ROOT); + if (lower.endsWith(".md") || lower.endsWith(".markdown")) { + return "text/markdown"; + } + if (lower.endsWith(".html") || lower.endsWith(".htm")) { + return "text/html"; + } + if (lower.endsWith(".txt")) { + return "text/plain"; + } + return rootDocument.mediaType(); + } + + private void refreshButtons() { + backButton.setEnabled(!backStack.isEmpty()); + forwardButton.setEnabled(!forwardStack.isEmpty()); + homeButton.setEnabled(!currentPath.equals(rootDocument.entryPath())); + } + + private void showError(Exception ex) { + SwingUtilities.invokeLater(() -> { + documentHost.removeAll(); + documentHost.add(new JLabel("Unable to open documentation: " + ex.getMessage()), BorderLayout.CENTER); + refreshButtons(); + documentHost.revalidate(); + documentHost.repaint(); + }); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/ContentMaterializer.java b/app/src/main/java/com/basic4gl/desktop/content/ContentMaterializer.java new file mode 100644 index 00000000..80a01046 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/ContentMaterializer.java @@ -0,0 +1,138 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.ContentPaths; +import com.basic4gl.desktop.spi.content.ContentResource; +import com.basic4gl.desktop.spi.content.ContentSource; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +public final class ContentMaterializer { + + private final Path cacheRoot; + + public ContentMaterializer(Path cacheRoot) { + this.cacheRoot = + Objects.requireNonNull(cacheRoot, "cacheRoot").toAbsolutePath().normalize(); + } + + public Path materialize(String pluginId, String providerId, String providerVersion, ContentSource source) + throws IOException { + Objects.requireNonNull(source, "source"); + Path finalRoot = versionRoot(pluginId, providerId, providerVersion); + if (Files.isDirectory(finalRoot)) { + return finalRoot; + } + + Path providerRoot = finalRoot.getParent(); + Files.createDirectories(providerRoot); + Path tempRoot = Files.createTempDirectory(providerRoot, ".tmp-"); + boolean moved = false; + try { + copyResources(source, tempRoot); + if (Files.isDirectory(finalRoot)) { + deleteRecursively(tempRoot); + return finalRoot; + } + moveIntoPlace(tempRoot, finalRoot); + moved = true; + return finalRoot; + } catch (FileAlreadyExistsException ex) { + deleteRecursively(tempRoot); + return finalRoot; + } finally { + if (!moved && Files.exists(tempRoot)) { + deleteRecursively(tempRoot); + } + } + } + + public void deleteObsoleteVersions(String pluginId, String providerId, String activeProviderVersion) + throws IOException { + Path providerRoot = cacheRoot + .resolve("content") + .resolve(safeKey(pluginId)) + .resolve(safeKey(providerId)) + .normalize(); + if (!Files.isDirectory(providerRoot)) { + return; + } + String activeKey = safeKey(activeProviderVersion); + try (var paths = Files.list(providerRoot)) { + for (Path path : paths.toList()) { + if (Files.isDirectory(path) && !path.getFileName().toString().equals(activeKey)) { + deleteRecursively(path); + } + } + } + } + + private void copyResources(ContentSource source, Path tempRoot) throws IOException { + Set copiedPaths = new HashSet<>(); + for (ContentResource resource : source.resources()) { + String resourcePath; + try { + resourcePath = ContentPaths.normalize(resource.path()); + } catch (IllegalArgumentException ex) { + throw new IOException("Unsafe content resource path: " + resource.path(), ex); + } + if (!copiedPaths.add(resourcePath)) { + throw new IOException("Duplicate content resource path: " + resourcePath); + } + Path target = tempRoot.resolve( + resourcePath.replace("/", tempRoot.getFileSystem().getSeparator())) + .normalize(); + if (!target.startsWith(tempRoot)) { + throw new IOException("Content resource escapes materialized root: " + resourcePath); + } + Files.createDirectories(target.getParent()); + try (InputStream input = source.open(resourcePath)) { + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + private Path versionRoot(String pluginId, String providerId, String providerVersion) { + return cacheRoot + .resolve("content") + .resolve(safeKey(pluginId)) + .resolve(safeKey(providerId)) + .resolve(safeKey(providerVersion)) + .normalize(); + } + + private static String safeKey(String value) { + if (value == null || value.isBlank()) { + return "_"; + } + String safe = value.trim().replaceAll("[^A-Za-z0-9._-]", "_"); + return safe.isBlank() ? "_" : safe; + } + + private static void moveIntoPlace(Path tempRoot, Path finalRoot) throws IOException { + try { + Files.move(tempRoot, finalRoot, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(tempRoot, finalRoot); + } + } + + private static void deleteRecursively(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (var paths = Files.walk(root)) { + for (Path path : + paths.sorted((left, right) -> right.compareTo(left)).toList()) { + Files.deleteIfExists(path); + } + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/DefaultAudioViewer.java b/app/src/main/java/com/basic4gl/desktop/content/DefaultAudioViewer.java new file mode 100644 index 00000000..4878e76e --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/DefaultAudioViewer.java @@ -0,0 +1,284 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerException; +import java.awt.*; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.sound.sampled.*; +import javax.swing.*; + +/** + * Default audio viewer with basic playback controls + * + * Features: + * - Play/pause/stop controls + * - Progress slider with seek capability + * - Volume control + * - Displays audio information (duration, format, etc.) + * - Supports WAV and AU formats (via Java Sound API) + */ +public class DefaultAudioViewer implements FileViewer { + + private final JPanel panel; + private final JButton playButton; + private final JButton pauseButton; + private final JButton stopButton; + private final JSlider progressSlider; + private final JSlider volumeSlider; + private final JLabel infoLabel; + private final JLabel timeLabel; + + private Clip audioClip; + private Thread updateThread; + private volatile boolean shouldStop = false; + private Path currentPath; + private String errorMessage = ""; + + public DefaultAudioViewer() { + this.panel = createUIPanel(); + this.playButton = new JButton("Play"); + this.pauseButton = new JButton("Pause"); + this.stopButton = new JButton("Stop"); + this.progressSlider = new JSlider(JSlider.HORIZONTAL, 0, 100, 0); + this.volumeSlider = new JSlider(JSlider.HORIZONTAL, 0, 100, 80); + this.infoLabel = new JLabel("No file loaded"); + this.timeLabel = new JLabel("00:00 / 00:00"); + + setupUI(); + setupListeners(); + } + + private JPanel createUIPanel() { + JPanel main = new JPanel(); + main.setLayout(new BorderLayout(10, 10)); + main.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + main.setBackground(Color.DARK_GRAY); + return main; + } + + private void setupUI() { + // Info panel + JPanel infoPanel = new JPanel(new BorderLayout(5, 5)); + infoPanel.setOpaque(false); + infoPanel.add(infoLabel, BorderLayout.CENTER); + infoPanel.add(timeLabel, BorderLayout.EAST); + + // Control buttons + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0)); + buttonPanel.setOpaque(false); + buttonPanel.add(playButton); + buttonPanel.add(pauseButton); + buttonPanel.add(stopButton); + pauseButton.setEnabled(false); + stopButton.setEnabled(false); + + // Progress slider + progressSlider.setEnabled(false); + JPanel progressPanel = new JPanel(new BorderLayout(5, 0)); + progressPanel.setOpaque(false); + progressPanel.add(new JLabel("Progress:"), BorderLayout.WEST); + progressPanel.add(progressSlider, BorderLayout.CENTER); + + // Volume control + JPanel volumePanel = new JPanel(new BorderLayout(5, 0)); + volumePanel.setOpaque(false); + volumePanel.add(new JLabel("Volume:"), BorderLayout.WEST); + volumePanel.add(volumeSlider, BorderLayout.CENTER); + + // Combine all + JPanel controlPanel = new JPanel(new GridLayout(3, 1, 0, 5)); + controlPanel.setOpaque(false); + controlPanel.add(buttonPanel); + controlPanel.add(progressPanel); + controlPanel.add(volumePanel); + + panel.add(infoPanel, BorderLayout.NORTH); + panel.add(controlPanel, BorderLayout.CENTER); + } + + private void setupListeners() { + playButton.addActionListener(e -> play()); + pauseButton.addActionListener(e -> pause()); + stopButton.addActionListener(e -> stop()); + + volumeSlider.addChangeListener(e -> { + if (audioClip != null) { + float volume = volumeSlider.getValue() / 100.0f; + FloatControl volumeControl = (FloatControl) audioClip.getControl(FloatControl.Type.MASTER_GAIN); + volumeControl.setValue(20.0f * (float) Math.log10(volume)); + } + }); + + progressSlider.addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mouseReleased(java.awt.event.MouseEvent e) { + if (audioClip != null && audioClip.isRunning()) { + long newPosition = (long) (progressSlider.getValue() / 100.0 * audioClip.getMicrosecondLength()); + audioClip.setMicrosecondPosition(newPosition); + } + } + }); + } + + @Override + public void loadFile(PluginContext context, Path path) throws FileViewerException { + try { + stop(); + + if (!Files.exists(path)) { + throw new FileViewerException("File not found: " + path); + } + + AudioInputStream audioStream = AudioSystem.getAudioInputStream(path.toFile()); + audioClip = AudioSystem.getClip(); + audioClip.open(audioStream); + + currentPath = path; + errorMessage = ""; + + // Update UI + AudioFormat format = audioStream.getFormat(); + infoLabel.setText(String.format( + "Loaded: %s | Format: %.0f Hz, %d bits, %d channels", + path.getFileName(), format.getSampleRate(), format.getSampleSizeInBits(), format.getChannels())); + + progressSlider.setEnabled(true); + playButton.setEnabled(true); + + updateProgressDisplay(); + + } catch (UnsupportedAudioFileException | IOException e) { + errorMessage = e.getMessage(); + throw new FileViewerException("Failed to load audio file: " + e.getMessage(), e); + } catch (LineUnavailableException e) { + errorMessage = e.getMessage(); + throw new FileViewerException("Audio line not available: " + e.getMessage(), e); + } + } + + private void play() { + if (audioClip != null) { + if (audioClip.isRunning()) { + return; + } + audioClip.start(); + playButton.setEnabled(false); + pauseButton.setEnabled(true); + stopButton.setEnabled(true); + startUpdateThread(); + } + } + + private void pause() { + if (audioClip != null && audioClip.isRunning()) { + audioClip.stop(); + playButton.setEnabled(true); + pauseButton.setEnabled(false); + } + } + + private void stop() { + shouldStop = true; + if (audioClip != null) { + audioClip.stop(); + audioClip.setMicrosecondPosition(0); + } + playButton.setEnabled(true); + pauseButton.setEnabled(false); + stopButton.setEnabled(false); + progressSlider.setValue(0); + timeLabel.setText("00:00 / 00:00"); + } + + private void startUpdateThread() { + if (updateThread == null || !updateThread.isAlive()) { + shouldStop = false; + updateThread = new Thread(this::updateProgress); + updateThread.setDaemon(true); + updateThread.start(); + } + } + + private void updateProgress() { + while (!shouldStop && audioClip != null && audioClip.isRunning()) { + try { + updateProgressDisplay(); + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + updateProgressDisplay(); + } + + private void updateProgressDisplay() { + if (audioClip != null) { + long duration = audioClip.getMicrosecondLength(); + long current = audioClip.getMicrosecondPosition(); + + if (duration > 0) { + int progress = (int) ((current * 100.0) / duration); + progressSlider.setValue(progress); + } + + timeLabel.setText(formatTime(current) + " / " + formatTime(duration)); + } + } + + private String formatTime(long microseconds) { + long seconds = microseconds / 1_000_000; + long minutes = seconds / 60; + long remainingSeconds = seconds % 60; + return String.format("%02d:%02d", minutes, remainingSeconds); + } + + @Override + public JComponent getComponent() { + return panel; + } + + @Override + public boolean canHandle(String filename, String mimeType) { + String lower = (filename != null ? filename.toLowerCase() : ""); + String mime = (mimeType != null ? mimeType.toLowerCase() : ""); + + // Check by extension - Java Sound API natively supports WAV and AU + if (lower.endsWith(".wav") || lower.endsWith(".au")) { + return true; + } + + // Check by MIME type + return mime.equals("audio/wav") || mime.equals("audio/x-wav") || mime.equals("audio/basic"); + } + + @Override + public String getName() { + return "Audio Viewer"; + } + + @Override + public String getVersion() { + return "1.0.0"; + } + + @Override + public void dispose() { + shouldStop = true; + if (audioClip != null) { + audioClip.close(); + audioClip = null; + } + if (updateThread != null) { + try { + updateThread.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + currentPath = null; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/DefaultAudioViewerProvider.java b/app/src/main/java/com/basic4gl/desktop/content/DefaultAudioViewerProvider.java new file mode 100644 index 00000000..ed940813 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/DefaultAudioViewerProvider.java @@ -0,0 +1,28 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerMetadata; +import com.basic4gl.desktop.spi.content.FileViewerProvider; + +/** + * Provider for DefaultAudioViewer + */ +public class DefaultAudioViewerProvider implements FileViewerProvider { + + private static final FileViewerMetadata METADATA = new FileViewerMetadata( + "Audio Viewer", + "1.0.0", + "Default viewer for audio files (WAV, AU)", + new String[] {".wav", ".au"}, + new String[] {"audio/wav", "audio/x-wav", "audio/basic"}); + + @Override + public FileViewer createViewer() { + return new DefaultAudioViewer(); + } + + @Override + public FileViewerMetadata getMetadata() { + return METADATA; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/DefaultImageViewer.java b/app/src/main/java/com/basic4gl/desktop/content/DefaultImageViewer.java new file mode 100644 index 00000000..104b1025 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/DefaultImageViewer.java @@ -0,0 +1,131 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerException; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.swing.*; + +/** + * Default image viewer with sane configuration + * + * Features: + * - Supports common image formats (PNG, JPEG, GIF, BMP, etc.) + * - Automatic scaling to fit window + * - Preserves aspect ratio + * - Efficient memory usage + * - Scrollable for large images + */ +public class DefaultImageViewer implements FileViewer { + + private final JPanel panel; + private BufferedImage image; + private Path currentPath; + private String errorMessage = ""; + + public DefaultImageViewer() { + this.panel = new JPanel() { + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + if (image != null) { + Graphics2D g2d = (Graphics2D) g; + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + + int panelWidth = getWidth(); + int panelHeight = getHeight(); + int imgWidth = image.getWidth(); + int imgHeight = image.getHeight(); + + // Calculate scaling to fit panel while preserving aspect ratio + double scale = Math.min((double) panelWidth / imgWidth, (double) panelHeight / imgHeight); + scale = Math.min(scale, 1.0); // Don't upscale + + int scaledWidth = (int) (imgWidth * scale); + int scaledHeight = (int) (imgHeight * scale); + + int x = (panelWidth - scaledWidth) / 2; + int y = (panelHeight - scaledHeight) / 2; + + g2d.drawImage(image, x, y, scaledWidth, scaledHeight, null); + } + } + }; + + panel.setBackground(Color.DARK_GRAY); + panel.setLayout(null); + } + + @Override + public void loadFile(PluginContext context, Path path) throws FileViewerException { + try { + if (!Files.exists(path)) { + throw new FileViewerException("File not found: " + path); + } + + byte[] imageBytes = Files.readAllBytes(path); + image = javax.imageio.ImageIO.read(new java.io.ByteArrayInputStream(imageBytes)); + + if (image == null) { + throw new FileViewerException("Unable to read image file. Unsupported format?"); + } + + currentPath = path; + errorMessage = ""; + panel.repaint(); + + } catch (Exception e) { + errorMessage = e.getMessage(); + throw new FileViewerException("Failed to load image: " + e.getMessage(), e); + } + } + + @Override + public JComponent getComponent() { + return panel; + } + + @Override + public boolean canHandle(String filename, String mimeType) { + String lower = (filename != null ? filename.toLowerCase() : ""); + String mime = (mimeType != null ? mimeType.toLowerCase() : ""); + + // Check by extension + if (lower.endsWith(".png") + || lower.endsWith(".jpg") + || lower.endsWith(".jpeg") + || lower.endsWith(".gif") + || lower.endsWith(".bmp") + || lower.endsWith(".webp") + || lower.endsWith(".tiff") + || lower.endsWith(".tif") + || lower.endsWith(".ico")) { + return true; + } + + // Check by MIME type + return mime.startsWith("image/"); + } + + @Override + public String getName() { + return "Image Viewer"; + } + + @Override + public String getVersion() { + return "1.0.0"; + } + + @Override + public void dispose() { + if (image != null) { + image.flush(); + image = null; + } + currentPath = null; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/DefaultImageViewerProvider.java b/app/src/main/java/com/basic4gl/desktop/content/DefaultImageViewerProvider.java new file mode 100644 index 00000000..0ab89398 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/DefaultImageViewerProvider.java @@ -0,0 +1,30 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerMetadata; +import com.basic4gl.desktop.spi.content.FileViewerProvider; + +/** + * Provider for DefaultImageViewer + */ +public class DefaultImageViewerProvider implements FileViewerProvider { + + private static final FileViewerMetadata METADATA = new FileViewerMetadata( + "Image Viewer", + "1.0.0", + "Default viewer for common image formats", + new String[] {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif", ".ico"}, + new String[] { + "image/png", "image/jpeg", "image/gif", "image/bmp", "image/webp", "image/tiff", "image/x-icon" + }); + + @Override + public FileViewer createViewer() { + return new DefaultImageViewer(); + } + + @Override + public FileViewerMetadata getMetadata() { + return METADATA; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java b/app/src/main/java/com/basic4gl/desktop/content/FileEditor.java similarity index 86% rename from app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java rename to app/src/main/java/com/basic4gl/desktop/content/FileEditor.java index fe10c187..17e99c33 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileEditor.java @@ -1,5 +1,9 @@ -package com.basic4gl.desktop.editor; +package com.basic4gl.desktop.content; +import com.basic4gl.desktop.editor.DualGutterScrollPane; +import com.basic4gl.desktop.editor.HoverIconRowHeader; +import com.basic4gl.desktop.editor.IFileEditorActionListener; +import com.basic4gl.desktop.editor.IToggleBreakpointListener; import com.basic4gl.desktop.language.Basic4GLFoldParser; import com.basic4gl.desktop.util.EditorUtil; import com.basic4gl.desktop.util.IFileManager; @@ -11,6 +15,10 @@ import java.io.FileWriter; import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import javax.swing.*; import javax.swing.filechooser.FileNameExtensionFilter; import javax.swing.text.BadLocationException; @@ -21,8 +29,10 @@ import org.fife.ui.rtextarea.*; public class FileEditor implements SearchListener { - public static final String DEFAULT_NAME = "[Unnamed]"; + public static final String DEFAULT_NAME = "Untitled"; + private static final int HEADER_BOOKMARK = 0; + private static final int HEADER_BREAK_PT = 1; private static final String ACTION_NEXT_BOOKMARK = "RTA.NextBookmarkAction"; private static final String ACTION_PREV_BOOKMARK = "RTA.PrevBookmarkAction"; private static final String ACTION_TOGGLE_BOOKMARK = "RTA.ToggleBookmarkAction"; @@ -48,17 +58,19 @@ public class FileEditor implements SearchListener { private final ReplaceToolBar replaceToolBar; private final CollapsibleSectionPanel csp; private final DualGutterScrollPane scrollPane; - private final RSyntaxTextArea editorPane; private final JPopupMenu gutterPopup; private int gutterPopupLine = -1; + // Package-protected to allow TextFileViewer access + protected final RSyntaxTextArea editorPane; + protected String fileName; // Filename without path + protected String filePath; // Full path including name + protected boolean isModified; + protected boolean isSaved; // File exists on system // private Map lineHighlights; //Highlight lines with breakpoints - private String fileName; // Filename without path - private String filePath; // Full path including name - private boolean isModified; - private boolean isSaved; // File exists on system + public record BookmarkLine(int lineNumber, String lineText) {} public FileEditor( IFileEditorActionListener actionListener, @@ -335,7 +347,73 @@ public File getFile() { } public String getShortFilename() { - return !fileName.isEmpty() ? new File(fileName).getName() : DEFAULT_NAME.toLowerCase(); + return !fileName.isEmpty() ? new File(fileName).getName() : DEFAULT_NAME; + } + + private static String getNextDefaultName(IFileManager fileManager) { + if (fileManager instanceof FileManager manager) { + List existingNames = new ArrayList<>(); + for (FileEditor editor : manager.getFileEditors()) { + if (editor != null) { + existingNames.add(editor.fileName); + } + } + return getNextDefaultName(existingNames); + } + + return DEFAULT_NAME; + } + + static String getNextDefaultName(Collection existingNames) { + Set usedIndexes = new HashSet<>(); + for (String existingName : existingNames) { + int index = getDefaultNameIndex(existingName); + if (index > 0) { + usedIndexes.add(index); + } + } + + int index = 1; + while (usedIndexes.contains(index)) { + index++; + } + + return formatDefaultName(index); + } + + private static boolean isDefaultName(String name) { + return getDefaultNameIndex(name) > 0; + } + + private static int getDefaultNameIndex(String name) { + if (name == null) { + return -1; + } + + if (DEFAULT_NAME.equalsIgnoreCase(name)) { + return 1; + } + + String prefix = DEFAULT_NAME + " "; + if (!name.regionMatches(true, 0, prefix, 0, prefix.length())) { + return -1; + } + + String suffix = name.substring(prefix.length()); + try { + int index = Integer.parseInt(suffix); + if (index >= 2 && Integer.toString(index).equals(suffix)) { + return index; + } + } catch (NumberFormatException ignored) { + return -1; + } + + return -1; + } + + private static String formatDefaultName(int index) { + return index == 1 ? DEFAULT_NAME : DEFAULT_NAME + " " + index; } public boolean isModified() { @@ -538,15 +616,52 @@ public void toggleFindToolBar(boolean replace) { } public void toggleBookmark() { + int line; try { - int line = editorPane.getLineOfOffset(editorPane.getCaretPosition()); + line = editorPane.getLineOfOffset(editorPane.getCaretPosition()); toggleBookmarkAtLine(line); } catch (BadLocationException ex) { + line = -1; ex.printStackTrace(); System.out.println(editorPane.getCaretPosition()); } + actionListener.onBookmarksChanged(getFilePath()); } + public List getBookmarks() { + ArrayList points = new ArrayList<>(); + HoverIconRowHeader gutter = scrollPane.getBookmarkHeader(); + if (gutter == null) { + return points; + } + GutterIconInfo[] bookmarks = gutter.getBookmarks(); + for (GutterIconInfo info : bookmarks) { + try { + int line = editorPane.getLineOfOffset(info.getMarkedOffset()); + String lineText = getLineText(line); + points.add(new BookmarkLine(line, lineText)); + } catch (BadLocationException ex) { + ex.printStackTrace(); + } + } + return points; + } + public void goToLine(int lineNumber) { + int safeLine = Math.max(0, lineNumber); + try { + int maxLine = Math.max(0, editorPane.getLineCount() - 1); + safeLine = Math.min(safeLine, maxLine); + int offset = editorPane.getLineStartOffset(safeLine); + if (editorPane.isCodeFoldingEnabled()) { + editorPane.getFoldManager().ensureOffsetNotInClosedFold(offset); + } + editorPane.requestFocusInWindow(); + editorPane.setCaretPosition(offset); + } catch (BadLocationException ble) { + UIManager.getLookAndFeel().provideErrorFeedback(editorPane); + ble.printStackTrace(); + } + } /** * Toggles whether the given line has a bookmark and keeps the bookmark * column's visibility in sync. @@ -561,6 +676,16 @@ public void toggleBookmarkAtLine(int line) { ex.printStackTrace(); } } + private String getLineText(int line) { + try { + int start = editorPane.getLineStartOffset(line); + int end = editorPane.getLineEndOffset(line); + String text = editorPane.getText(start, Math.max(0, end - start)); + return text == null ? "" : text.strip(); + } catch (BadLocationException ex) { + return ""; + } + } /** * Shows the shared gutter context menu on a popup-trigger event, targeting diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileManager.java b/app/src/main/java/com/basic4gl/desktop/content/FileManager.java new file mode 100644 index 00000000..284fedb0 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/FileManager.java @@ -0,0 +1,277 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.util.IFileManager; +import com.basic4gl.language.core.internal.Mutable; +import java.io.File; +import java.util.Vector; +import javax.swing.*; + +public class FileManager implements IFileManager { + + private final Vector fileEditors = new Vector<>(); + private String runnableFilePath; + private FileEditor runnableFileEditor; + + private String currentDirectory; // Current working directory + + private String appDirectory; // Application directory (where the basic4glj executable is) + private String fileDirectory; // File I/O in this directory + private String runDirectory; // Basic4GL programs are run in this directory + + private final IFileManagerListener listener; + + public FileManager(IFileManagerListener listener) { + this.listener = listener; + } + + @Override + public int editorCount() { + return fileEditors.size(); + } + + @Override + public JTextArea getEditor(int index) { + return fileEditors.get(index).getEditorPane(); + } + + public String getFilename(int index) { + return fileEditors.get(index).getFilePath(); + } + + private FileEditor getEditorOrNull(int index) { + if (index < 0 || index >= fileEditors.size()) { + return null; + } + return fileEditors.get(index); + } + + public void setCurrentDirectory(String path) { + currentDirectory = path; + if (listener != null) { + listener.onCurrentDirectoryChanged(path); + } + } + + public String getCurrentDirectory() { + return currentDirectory; + } + + @Override + public String getAppDirectory() { + return appDirectory; + } + + public int getFileTabIndex(String filename) { + File file = new File(getCurrentDirectory(), filename); + return getTabIndex(file.getAbsolutePath()); + } + + public int getTabIndex(String path) { + int i = 0; + boolean found = false; + for (; i < fileEditors.size(); i++) { + FileEditor editor = fileEditors.get(i); + if (editor != null && editor.getFilePath().equals(path)) { + found = true; + break; + } + } + return found ? i : -1; + } + + public void selectPreviousBreakpoint(int i) { + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.gotoNextBreakpoint(false); + } + } + + public void selectNextBreakpoint(int i) { + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.gotoNextBreakpoint(true); + } + } + + public void toggleBookmark(int i) { + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.toggleBookmark(); + } + } + + public void selectPreviousBookmark(int i) { + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.gotoNextBookmark(false); + } + } + + public void selectNextBookmark(int i) { + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.gotoNextBookmark(true); + } + } + + public void selectAll(int i) { + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.getEditorPane().selectAll(); + } + } + + public void paste(int i) { + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.getEditorPane().paste(); + } + } + + public void copy(int i) { + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.getEditorPane().copy(); + } + } + + public void cut(int i) { + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.getEditorPane().cut(); + } + } + + public void redo(int i) { + FileEditor editor = getEditorOrNull(i); + if (editor != null && editor.canRedo()) { + editor.redoLastAction(); + } + } + + public void undo(int i) { + FileEditor editor = getEditorOrNull(i); + if (editor != null && editor.canUndo()) { + editor.undoLastAction(); + } + } + + public void toggleBreakpoint(int i) { + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.toggleBreakpoint(); + } + } + + public void setReadOnly(boolean readOnly) { + for (int i = 0; i < fileEditors.size(); i++) { + FileEditor editor = fileEditors.get(i); + if (editor != null) { + editor.getEditorPane().setEditable(!readOnly); + } + } + } + + public boolean isMultifileModified(Mutable description) { + boolean result = false; + String desc = ""; + + for (int i = 0; i < fileEditors.size(); i++) { + FileEditor editor = fileEditors.get(i); + if (editor != null && editor.isModified()) { + result = true; + if (!desc.isEmpty()) { + desc += ", "; + } + String filename = editor.getShortFilename(); + + desc += filename; + } + } + description.set(desc); + return result; + } + + public void setAppDirectory(String appDirectory) { + this.appDirectory = appDirectory; + } + + public String getFileDirectory() { + return fileDirectory; + } + + public void setFileDirectory(String fileDirectory) { + this.fileDirectory = fileDirectory; + } + + public String getRunDirectory() { + return runDirectory; + } + + public void setRunDirectory(String runDirectory) { + this.runDirectory = runDirectory; + } + + public Vector getFileEditors() { + return fileEditors; + } + + public String getRunnableFilePath() { + ensureRunnableFileValid(); + if (runnableFileEditor != null) { + return runnableFileEditor.getFilePath(); + } + return runnableFilePath; + } + + public void setRunnableFilePath(String runnableFilePath) { + this.runnableFilePath = runnableFilePath; + this.runnableFileEditor = null; + ensureRunnableFileValid(); + } + + public void setRunnableFileEditor(FileEditor runnableFileEditor) { + this.runnableFileEditor = runnableFileEditor; + this.runnableFilePath = runnableFileEditor != null ? runnableFileEditor.getFilePath() : null; + ensureRunnableFileValid(); + } + + public int getRunnableFileIndex() { + ensureRunnableFileValid(); + if (runnableFileEditor != null) { + return fileEditors.indexOf(runnableFileEditor); + } + if (runnableFilePath == null || runnableFilePath.isBlank()) { + return -1; + } + return getTabIndex(runnableFilePath); + } + + public void ensureRunnableFileValid() { + if (fileEditors.isEmpty()) { + runnableFilePath = null; + runnableFileEditor = null; + return; + } + + if (runnableFileEditor != null && fileEditors.contains(runnableFileEditor)) { + runnableFilePath = runnableFileEditor.getFilePath(); + return; + } + runnableFileEditor = null; + + if (runnableFilePath != null && !runnableFilePath.isBlank() && getTabIndex(runnableFilePath) != -1) { + return; + } + + for (FileEditor editor : fileEditors) { + if (editor != null) { + runnableFilePath = editor.getFilePath(); + runnableFileEditor = editor; + return; + } + } + runnableFilePath = null; + runnableFileEditor = null; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerFactory.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerFactory.java new file mode 100644 index 00000000..50dab91a --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerFactory.java @@ -0,0 +1,177 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.editor.IFileEditorActionListener; +import com.basic4gl.desktop.editor.IFileViewer; +import com.basic4gl.desktop.editor.IToggleBreakpointListener; +import com.basic4gl.desktop.spi.PluginContext; +import java.io.File; +import java.util.Locale; +import org.fife.ui.rsyntaxtextarea.LinkGenerator; +import org.fife.ui.rtextarea.SearchContext; + +/** + * Factory class that determines which viewer to use for different file types. + * This is the central place to configure which file types get which viewers. + */ +public class FileViewerFactory { + private FileViewerFactory() { + // Utility class + } + + /** + * Gets the appropriate viewer type for a given file. + * + * @param file the file to determine viewer type for + * @return the viewer type to use for this file + */ + public static IFileViewer.ViewerType getViewerType(File file) { + if (file == null) { + return IFileViewer.ViewerType.TEXT_EDITOR; + } + + String name = file.getName().toLowerCase(Locale.ROOT); + + // Image files + if (isImageFile(name)) { + return IFileViewer.ViewerType.IMAGE_VIEWER; + } + + // Audio files + if (isAudioFile(name)) { + return IFileViewer.ViewerType.AUDIO_VIEWER; + } + + // Markdown files + if (name.endsWith(".md")) { + return IFileViewer.ViewerType.MARKDOWN_VIEWER; + } + + // HTML files + if (name.endsWith(".html") || name.endsWith(".htm")) { + return IFileViewer.ViewerType.HTML_VIEWER; + } + + // Default to text editor + return IFileViewer.ViewerType.TEXT_EDITOR; + } + + /** + * Creates a file viewer for the given file with the specified viewer type preference. + * If the preferred viewer type is not available or the file type doesn't match, falls back + * to a sensible default. + * + * @param file the file to create a viewer for (can be null for new unsaved files) + * @param preferredViewerType the preferred viewer type (can be null to auto-detect) + * @param actionListener listener for file editor actions + * @param fileManager file manager instance + * @param toggleBreakpointListener listener for breakpoint toggles + * @param linkGenerator link generator for hyperlinks + * @param searchContext search context for find/replace + * @param pluginContext plugin context for accessing IDE services + * @return a new IFileViewer instance + */ + public static IFileViewer createViewer( + File file, + IFileViewer.ViewerType preferredViewerType, + IFileEditorActionListener actionListener, + com.basic4gl.desktop.util.IFileManager fileManager, + IToggleBreakpointListener toggleBreakpointListener, + LinkGenerator linkGenerator, + SearchContext searchContext, + PluginContext pluginContext) { + + IFileViewer.ViewerType viewerType = preferredViewerType; + + // Auto-detect if not preferred + if (viewerType == null && file != null) { + viewerType = getViewerType(file); + } + + // Create the appropriate viewer + switch (viewerType) { + case IMAGE_VIEWER: + if (file != null && isImageFile(file.getName().toLowerCase(Locale.ROOT))) { + return new ImageFileViewer(file); + } + case HEX_VIEWER: + return new HexFileViewer(file); + case AUDIO_VIEWER: + if (file != null && isAudioFile(file.getName().toLowerCase(Locale.ROOT))) { + return new AudioFileViewer(file); + } + case MARKDOWN_VIEWER: + if (file != null && file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { + return new MarkdownViewer( + pluginContext, + file, + actionListener, + fileManager, + toggleBreakpointListener, + linkGenerator, + searchContext); + } + case HTML_VIEWER: + if (file != null + && (file.getName().toLowerCase(Locale.ROOT).endsWith(".html") + || file.getName().toLowerCase(Locale.ROOT).endsWith(".htm"))) { + return new HtmlViewer( + pluginContext, + file, + actionListener, + fileManager, + toggleBreakpointListener, + linkGenerator, + searchContext); + } + case TEXT_EDITOR: + default: + return new TextFileViewer( + file, actionListener, fileManager, toggleBreakpointListener, linkGenerator, searchContext); + } + } + + /** + * Determines if a file is an image file + */ + public static boolean isImageFile(String filename) { + String lower = filename.toLowerCase(Locale.ROOT); + return lower.endsWith(".png") + || lower.endsWith(".jpg") + || lower.endsWith(".jpeg") + || lower.endsWith(".gif") + || lower.endsWith(".bmp") + || lower.endsWith(".webp") + || lower.endsWith(".ico") + || lower.endsWith(".svg") + || lower.endsWith(".tiff") + || lower.endsWith(".tif"); + } + + /** + * Determines if a file is an audio file + */ + public static boolean isAudioFile(String filename) { + String lower = filename.toLowerCase(Locale.ROOT); + return lower.endsWith(".wav") + || lower.endsWith(".ogg") + || lower.endsWith(".mp3") + || lower.endsWith(".flac") + || lower.endsWith(".aac") + || lower.endsWith(".m4a") + || lower.endsWith(".wma") + || lower.endsWith(".aiff"); + } + + /** + * Determines if a file should be opened with a hex viewer + */ + public static boolean isBinaryFile(String filename) { + // Files that are binary but might need viewing + String lower = filename.toLowerCase(Locale.ROOT); + return lower.endsWith(".jar") + || lower.endsWith(".zip") + || lower.endsWith(".bin") + || lower.endsWith(".dat") + || lower.endsWith(".class"); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java new file mode 100644 index 00000000..2dddb825 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java @@ -0,0 +1,115 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.content.FileViewerException; +import java.nio.file.Path; + +/** + * Convenience facade for using the file viewer system + * + * This class provides a simple API for the most common use cases: + * - Load and display a file + * - Check if a file type is supported + * - List available viewers + */ +public class FileViewerManager { + + private static final FileViewerRegistry registry = new FileViewerRegistry(); + private static boolean initialized = false; + + private FileViewerManager() { + // Static utility class + } + + /** + * Initialize the file viewer system (must be called once before use) + */ + public static void initialize() { + if (!initialized) { + registry.initialize(); + initialized = true; + } + } + + /** + * Load a file with an appropriate viewer + * + * Example usage: + * try { + * FileViewerManager.initialize(); + * FileViewerResult result = FileViewerManager.loadFile(Paths.get("image.png")); + * if (result.isSuccess()) { + * JFrame frame = new JFrame("File Viewer"); + * frame.add(result.getViewer().getComponent()); + * frame.setSize(800, 600); + * frame.setVisible(true); + * } else { + * System.err.println("Error: " + result.getError()); + * } + * } catch (Exception e) { + * e.printStackTrace(); + * } + * + * @param filepath Path to file to view + * @return FileViewerResult containing viewer or error details + */ + public static FileViewerRegistry.FileViewerResult loadFile(PluginContext context, Path filepath) { + if (!initialized) { + initialize(); + } + + FileViewerRegistry.FileViewerResult result = registry.findViewer(filepath); + if (result.isSuccess()) { + try { + result.getViewer().loadFile(context, filepath); + } catch (FileViewerException e) { + return new FileViewerRegistry.FileViewerResult(null, null, e.getMessage()); + } + } + return result; + } + + /** + * Check if a file type is supported by any registered viewer + */ + public static boolean isSupported(String filename) { + if (!initialized) { + initialize(); + } + for (com.basic4gl.desktop.spi.content.FileViewerMetadata viewer : registry.getAvailableViewers()) { + String ext = getFileExtension(filename); + if (ext != null && viewer.supportsExtension(ext)) { + return true; + } + } + return false; + } + + /** + * Get all registered viewers + */ + public static java.util.List getAvailableViewers() { + if (!initialized) { + initialize(); + } + return registry.getAvailableViewers(); + } + + /** + * Get last error message + */ + public static String getLastError() { + return registry.getLastError(); + } + + private static String getFileExtension(String filename) { + if (filename == null || !filename.contains(".")) { + return null; + } + int lastDot = filename.lastIndexOf("."); + if (lastDot > 0 && lastDot < filename.length() - 1) { + return filename.substring(lastDot); + } + return null; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java new file mode 100644 index 00000000..3d9e033c --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java @@ -0,0 +1,187 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.FileViewer; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Registry for discovering and managing file viewers + * + * Handles ServiceLoader-based discovery of FileViewerProviders and + * maintains a registry of available viewers. + */ +public class FileViewerRegistry { + + private final Map providers = new HashMap<>(); + private String lastError = ""; + + /** + * Initialize the registry and discover all available viewers + */ + public void initialize() { + providers.clear(); + lastError = ""; + + try { + ServiceLoader loader = + ServiceLoader.load(com.basic4gl.desktop.spi.content.FileViewerProvider.class); + + for (com.basic4gl.desktop.spi.content.FileViewerProvider provider : loader) { + com.basic4gl.desktop.spi.content.FileViewerMetadata metadata = provider.getMetadata(); + if (metadata != null && metadata.getName() != null) { + providers.put(metadata.getName(), provider); + } + } + } catch (Exception e) { + lastError = "Failed to discover file viewers: " + e.getMessage(); + } + } + + /** + * Find a suitable viewer for the given file + * @param filepath Path to the file + * @return FileViewerResult with viewer instance or error details + */ + public FileViewerResult findViewer(Path filepath) { + String filename = filepath.getFileName().toString(); + String extension = getFileExtension(filename); + String mimeType = guessMimeType(filename); + + for (com.basic4gl.desktop.spi.content.FileViewerProvider provider : providers.values()) { + com.basic4gl.desktop.spi.content.FileViewerMetadata metadata = provider.getMetadata(); + if (metadata != null) { + if ((extension != null && metadata.supportsExtension(extension)) + || (mimeType != null && metadata.supportsMimeType(mimeType))) { + try { + FileViewer viewer = provider.createViewer(); + return new FileViewerResult(viewer, metadata); + } catch (Exception e) { + lastError = "Failed to create viewer " + metadata.getName() + ": " + e.getMessage(); + return new FileViewerResult(null, null, lastError); + } + } + } + } + + lastError = "No viewer found for: " + filename; + return new FileViewerResult(null, null, lastError); + } + + /** + * Get all registered viewers + */ + public List getAvailableViewers() { + return providers.values().stream() + .map(com.basic4gl.desktop.spi.content.FileViewerProvider::getMetadata) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * Get error message from last operation + */ + public String getLastError() { + return lastError; + } + + /** + * Extract file extension from filename + */ + private String getFileExtension(String filename) { + if (filename == null || !filename.contains(".")) { + return null; + } + int lastDot = filename.lastIndexOf("."); + if (lastDot > 0 && lastDot < filename.length() - 1) { + return filename.substring(lastDot); + } + return null; + } + + /** + * Guess MIME type based on file extension (sane defaults) + */ + private String guessMimeType(String filename) { + String ext = getFileExtension(filename); + if (ext == null) { + return null; + } + + return switch (ext.toLowerCase()) { + // Images + case ".jpg", ".jpeg" -> "image/jpeg"; + case ".png" -> "image/png"; + case ".gif" -> "image/gif"; + case ".bmp" -> "image/bmp"; + case ".webp" -> "image/webp"; + case ".svg" -> "image/svg+xml"; + case ".tiff", ".tif" -> "image/tiff"; + case ".ico" -> "image/x-icon"; + case ".pcx" -> "image/x-pcx"; + + // Audio + case ".mp3" -> "audio/mpeg"; + case ".wav" -> "audio/wav"; + case ".flac" -> "audio/flac"; + case ".ogg", ".oga" -> "audio/ogg"; + case ".m4a" -> "audio/mp4"; + case ".aac" -> "audio/aac"; + case ".wma" -> "audio/x-ms-wma"; + case ".aiff", ".aif" -> "audio/aiff"; + + // Video + case ".mp4" -> "video/mp4"; + case ".webm" -> "video/webm"; + case ".mkv" -> "video/x-matroska"; + case ".avi" -> "video/x-msvideo"; + case ".mov" -> "video/quicktime"; + case ".flv" -> "video/x-flv"; + + // Text + case ".txt" -> "text/plain"; + case ".json" -> "application/json"; + case ".xml" -> "application/xml"; + case ".html", ".htm" -> "text/html"; + case ".css" -> "text/css"; + + default -> null; + }; + } + + /** + * Result of finding a viewer + */ + public static class FileViewerResult { + private final FileViewer viewer; + private final com.basic4gl.desktop.spi.content.FileViewerMetadata metadata; + private final String error; + + public FileViewerResult(FileViewer viewer, com.basic4gl.desktop.spi.content.FileViewerMetadata metadata) { + this(viewer, metadata, null); + } + + public FileViewerResult( + FileViewer viewer, com.basic4gl.desktop.spi.content.FileViewerMetadata metadata, String error) { + this.viewer = viewer; + this.metadata = metadata; + this.error = error; + } + + public FileViewer getViewer() { + return viewer; + } + + public com.basic4gl.desktop.spi.content.FileViewerMetadata getMetadata() { + return metadata; + } + + public String getError() { + return error; + } + + public boolean isSuccess() { + return viewer != null; + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerWrapper.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerWrapper.java new file mode 100644 index 00000000..b9f825ee --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerWrapper.java @@ -0,0 +1,107 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.editor.IFileViewer; +import java.io.File; + +/** + * Wrapper class that bridges IFileViewer with FileEditor for backward compatibility. + * This allows the IDE to store and manage different viewer types while maintaining + * compatibility with existing FileEditor-based code. + */ +public class FileViewerWrapper { + private final IFileViewer viewer; + private final FileEditor textEditor; // Only non-null if viewer is TextFileViewer + + public FileViewerWrapper(IFileViewer viewer) { + this.viewer = viewer; + if (viewer instanceof TextFileViewer textFileViewer) { + this.textEditor = textFileViewer.getFileEditor(); + } else if (viewer instanceof HtmlViewer htmlViewer) { + this.textEditor = htmlViewer.getFileEditor(); + } else { + this.textEditor = null; + } + } + + public FileViewerWrapper(FileEditor editor) { + this(new TextFileViewer(editor)); + } + + /** + * Gets the viewer interface for general file operations + */ + public IFileViewer getViewer() { + return viewer; + } + + /** + * Gets the FileEditor if this is a text viewer, otherwise null + * For backward compatibility with code expecting FileEditor + */ + public FileEditor getFileEditor() { + return textEditor; + } + + /** + * Returns true if this wrapper contains a text editor + */ + public boolean isTextEditor() { + return textEditor != null; + } + + /** + * Gets the viewer type + */ + public IFileViewer.ViewerType getViewerType() { + return viewer.getViewerType(); + } + + /** + * Gets the title + */ + public String getTitle() { + return viewer.getTitle(); + } + + /** + * Gets the file path + */ + public String getFilePath() { + return viewer.getFilePath(); + } + + /** + * Gets the File object + */ + public File getFile() { + return viewer.getFile(); + } + + /** + * Gets the short filename + */ + public String getShortFilename() { + return viewer.getShortFilename(); + } + + /** + * Gets the content pane + */ + public javax.swing.JComponent getContentPane() { + return viewer.getContentPane(); + } + + /** + * Checks if modified + */ + public boolean isModified() { + return viewer.isModified(); + } + + /** + * Sets modified state + */ + public void setModified() { + viewer.setModified(); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/HexFileViewer.java b/app/src/main/java/com/basic4gl/desktop/content/HexFileViewer.java new file mode 100644 index 00000000..f42630e4 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/HexFileViewer.java @@ -0,0 +1,165 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.editor.IFileViewer; +import java.awt.*; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import javax.swing.*; + +/** + * Hex file viewer for displaying binary files in hexadecimal format. + * Provides a read-only view showing: + * - Byte offsets on the left + * - Hexadecimal representation in the center + * - ASCII representation on the right + * - File statistics + */ +public class HexFileViewer implements IFileViewer { + private final File file; + private final JPanel contentPanel; + private final JTextArea hexArea; + private static final int BYTES_PER_LINE = 16; + + public HexFileViewer(File file) { + this.file = file; + this.hexArea = new JTextArea(); + this.contentPanel = new JPanel(new BorderLayout()); + + setupUI(); + + if (file != null && file.exists()) { + loadFileAsHex(); + } else { + showError("File not found or is not readable"); + } + } + + private void setupUI() { + hexArea.setEditable(false); + hexArea.setFont(new Font("Monospaced", Font.PLAIN, 12)); + hexArea.setTabSize(4); + + JScrollPane scrollPane = new JScrollPane(hexArea); + contentPanel.add(scrollPane, BorderLayout.CENTER); + + // Info panel at bottom + JPanel infoPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JLabel infoLabel = new JLabel(" File: " + (file != null ? file.getName() : "[Binary]")); + infoPanel.add(infoLabel); + contentPanel.add(infoPanel, BorderLayout.SOUTH); + } + + private void loadFileAsHex() { + try { + StringBuilder hexContent = new StringBuilder(); + try (FileInputStream fis = new FileInputStream(file)) { + byte[] buffer = new byte[BYTES_PER_LINE]; + long offset = 0; + int bytesRead; + + hexContent.append("Offset Hex Bytes ASCII\n"); + hexContent.append("-------- -------------------------------------------------- ----------------\n"); + + while ((bytesRead = fis.read(buffer)) != -1) { + // Offset + hexContent.append(String.format("%08X ", offset)); + + // Hex representation + StringBuilder hexBuilder = new StringBuilder(); + StringBuilder asciiBuilder = new StringBuilder(); + + for (int i = 0; i < BYTES_PER_LINE; i++) { + if (i < bytesRead) { + byte b = buffer[i]; + hexBuilder.append(String.format("%02X ", b & 0xFF)); + + char c = (char) (b & 0xFF); + if (Character.isWhitespace(c) && c != ' ') { + asciiBuilder.append('.'); + } else if (c >= 32 && c < 127) { + asciiBuilder.append(c); + } else { + asciiBuilder.append('.'); + } + } else { + hexBuilder.append(" "); + asciiBuilder.append(" "); + } + } + + hexContent.append(String.format("%-48s ", hexBuilder.toString())); + hexContent.append(asciiBuilder.toString()); + hexContent.append("\n"); + + offset += bytesRead; + } + } + hexArea.setText(hexContent.toString()); + hexArea.setCaretPosition(0); + } catch (IOException e) { + showError("Error reading file: " + e.getMessage()); + } + } + + private void showError(String message) { + hexArea.setText("Error: " + message); + } + + @Override + public String getTitle() { + if (file == null) { + return "[Binary]"; + } + return file.getName() + " (Hex)"; + } + + @Override + public String getFilePath() { + return file != null ? file.getAbsolutePath() : ""; + } + + @Override + public JComponent getContentPane() { + return contentPanel; + } + + @Override + public File getFile() { + return file; + } + + @Override + public String getShortFilename() { + return file != null ? file.getName() : "[Binary]"; + } + + @Override + public boolean isModified() { + // Binary files are read-only + return false; + } + + @Override + public void setModified() { + // Binary files are read-only + } + + @Override + public ViewerType getViewerType() { + return ViewerType.HEX_VIEWER; + } + + @Override + public boolean hasPreview() { + return false; + } + + @Override + public void setViewMode(ViewMode viewMode) {} + + @Override + public ViewMode getViewMode() { + return ViewMode.DEFAULT; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/HtmlViewer.java b/app/src/main/java/com/basic4gl/desktop/content/HtmlViewer.java new file mode 100644 index 00000000..b2db27cc --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/HtmlViewer.java @@ -0,0 +1,301 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.editor.IFileViewer; +import com.basic4gl.desktop.editor.IFileEditorActionListener; +import com.basic4gl.desktop.editor.IToggleBreakpointListener; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.util.IFileManager; +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerException; +import java.awt.*; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; +import javafx.application.Platform; +import javafx.embed.swing.JFXPanel; +import javafx.scene.Scene; +import javafx.scene.web.WebView; +import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import org.fife.ui.rsyntaxtextarea.LinkGenerator; +import org.fife.ui.rtextarea.SearchContext; + +public class HtmlViewer implements FileViewer, IFileViewer { + private static final AtomicBoolean JAVAFX_INITIALIZED = new AtomicBoolean(false); + + private final JPanel contentPane = new JPanel(new BorderLayout()); + private final JFXPanel panel; + + private WebView webView; + + private TextFileViewer textViewer; + + protected File file; + + protected String source; + protected boolean readOnly = false; + + protected ViewMode viewMode = ViewMode.PREVIEW; + + public HtmlViewer() { + panel = new JFXPanel(); + showViewMode(); + + ensureJavaFxInitialized(); + Platform.runLater(() -> { + try { + webView = new WebView(); + panel.setScene(new Scene(webView)); + } catch (Throwable ex) { + showDocsFallback(panel, "", ex); + } + }); + } + + public HtmlViewer(PluginContext pluginContext, File file) { + this(); + try { + loadFile(pluginContext, file.toPath()); + } catch (FileViewerException ex) { + pluginContext.dialogs().showDialog("Unable to load HTML file: " + ex.getMessage()); + } + } + + public HtmlViewer( + PluginContext pluginContext, + File file, + IFileEditorActionListener actionListener, + IFileManager fileManager, + IToggleBreakpointListener toggleBreakpointListener, + LinkGenerator linkGenerator, + SearchContext searchContext) { + this(); + textViewer = new TextFileViewer( + file, actionListener, fileManager, toggleBreakpointListener, linkGenerator, searchContext); + textViewer.getFileEditor().getEditorPane().getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + onEditorDocumentChanged(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + onEditorDocumentChanged(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + onEditorDocumentChanged(); + } + }); + try { + loadFile(pluginContext, file.toPath()); + } catch (FileViewerException ex) { + pluginContext.dialogs().showDialog("Unable to load HTML file: " + ex.getMessage()); + } + showViewMode(); + } + + @Override + public void loadFile(PluginContext context, Path path) throws FileViewerException { + file = path.toFile(); + + try { + + String html = Files.readString(path, StandardCharsets.UTF_8); + source = html; + + panel.putClientProperty("docs.path", path); + + loadHtmlContent(renderPreviewHtml(html)); + + } catch (IOException ex) { + context.dialogs().showDialog("Unable to load file: " + ex.getMessage()); + } catch (Throwable ex) { + context.dialogs().showDialog("Unable to render html file: " + ex.getMessage()); + } + } + + protected String renderPreviewHtml(String source) { + return source == null ? "" : source; + } + + protected void loadHtmlContent(String html) { + + ensureJavaFxInitialized(); + + Platform.runLater(() -> { + try { + webView.getEngine().loadContent(html, "text/html"); + } catch (Throwable ex) { + showDocsFallback(panel, "", ex); + } + }); + } + + @Override + public JComponent getComponent() { + return contentPane; + } + + @Override + public boolean canHandle(String filename, String mimeType) { + if (filename.toLowerCase().endsWith(".html") || filename.toLowerCase().endsWith(".htm")) { + return true; + } + + return mimeType.equals("text/html"); + } + + @Override + public String getName() { + return "HTML Viewer"; + } + + @Override + public String getVersion() { + return "1.0.0"; + } + + @Override + public void dispose() { + // No resources to dispose + } + + private void ensureJavaFxInitialized() { + if (!JAVAFX_INITIALIZED.compareAndSet(false, true)) { + return; + } + try { + Platform.startup(() -> {}); + } catch (IllegalStateException ignored) { + // Toolkit already initialized by JFXPanel startup. + } + Platform.setImplicitExit(false); + } + + private void showDocsFallback(JFXPanel panel, String html, Throwable ex) { + System.err.println("Unable to initialize JavaFX WebView: " + ex.getMessage()); + SwingUtilities.invokeLater(() -> { + JEditorPane fallbackPane = new JEditorPane(); + fallbackPane.setEditable(false); + fallbackPane.setContentType("text/html"); + fallbackPane.setText(html); + fallbackPane.setCaretPosition(0); + panel.setLayout(new BorderLayout()); + panel.add(new JScrollPane(fallbackPane), BorderLayout.CENTER); + panel.revalidate(); + panel.repaint(); + }); + } + + @Override + public String getTitle() { + return file != null ? file.getName() : "[HTML]"; + } + + @Override + public String getFilePath() { + return file != null ? file.getAbsolutePath() : ""; + } + + @Override + public JComponent getContentPane() { + return contentPane; + } + + @Override + public File getFile() { + return file; + } + + @Override + public String getShortFilename() { + return file != null ? file.getName() : "[HTML]"; + } + + @Override + public boolean isModified() { + return textViewer != null && textViewer.isModified(); + } + + @Override + public void setModified() { + if (textViewer != null) { + textViewer.setModified(); + } + } + + @Override + public ViewerType getViewerType() { + return ViewerType.HTML_VIEWER; + } + + @Override + public boolean hasPreview() { + return true; + } + + @Override + public void setViewMode(ViewMode viewMode) { + if (viewMode == ViewMode.DEFAULT) { + this.viewMode = ViewMode.PREVIEW; + } else { + this.viewMode = viewMode; + } + showViewMode(); + } + + @Override + public ViewMode getViewMode() { + return viewMode; + } + + public FileEditor getFileEditor() { + return textViewer != null ? textViewer.getFileEditor() : null; + } + + private void onEditorDocumentChanged() { + source = textViewer.getFileEditor().getEditorPane().getText(); + if (viewMode == ViewMode.PREVIEW || viewMode == ViewMode.EDITOR_AND_PREVIEW) { + loadHtmlContent(renderPreviewHtml(source)); + } + } + + private void showViewMode() { + contentPane.removeAll(); + removeFromParent(panel); + JComponent editorContent = textViewer != null ? textViewer.getContentPane() : null; + if (editorContent != null) { + removeFromParent(editorContent); + } + + if (viewMode == ViewMode.EDITOR && editorContent != null) { + contentPane.add(editorContent, BorderLayout.CENTER); + } else if (viewMode == ViewMode.EDITOR_AND_PREVIEW && editorContent != null) { + loadHtmlContent(renderPreviewHtml(textViewer.getFileEditor().getEditorPane().getText())); + JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, editorContent, panel); + splitPane.setResizeWeight(0.5); + contentPane.add(splitPane, BorderLayout.CENTER); + } else { + if (textViewer != null) { + loadHtmlContent(renderPreviewHtml(textViewer.getFileEditor().getEditorPane().getText())); + } + contentPane.add(panel, BorderLayout.CENTER); + } + + contentPane.revalidate(); + contentPane.repaint(); + } + + private void removeFromParent(Component component) { + Container parent = component.getParent(); + if (parent != null) { + parent.remove(component); + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/IFileManagerListener.java b/app/src/main/java/com/basic4gl/desktop/content/IFileManagerListener.java similarity index 71% rename from app/src/main/java/com/basic4gl/desktop/IFileManagerListener.java rename to app/src/main/java/com/basic4gl/desktop/content/IFileManagerListener.java index d8b6a173..0d2e54a0 100644 --- a/app/src/main/java/com/basic4gl/desktop/IFileManagerListener.java +++ b/app/src/main/java/com/basic4gl/desktop/content/IFileManagerListener.java @@ -1,4 +1,4 @@ -package com.basic4gl.desktop; +package com.basic4gl.desktop.content; public interface IFileManagerListener { void onCurrentDirectoryChanged(String directory); diff --git a/app/src/main/java/com/basic4gl/desktop/content/ImageFileViewer.java b/app/src/main/java/com/basic4gl/desktop/content/ImageFileViewer.java new file mode 100644 index 00000000..0607d776 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/ImageFileViewer.java @@ -0,0 +1,118 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.editor.IFileViewer; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import javax.imageio.ImageIO; +import javax.swing.*; + +/** + * Image file viewer for displaying PNG, JPG, GIF, BMP, and other image formats. + * Provides a read-only view with: + * - Automatic scaling to fit within the window + * - Maintains aspect ratio + * - Shows image dimensions in the title + */ +public class ImageFileViewer implements IFileViewer { + private final File file; + private final JPanel contentPanel; + private BufferedImage image; + private String errorMessage; + + public ImageFileViewer(File file) { + this.file = file; + this.contentPanel = new JPanel(new BorderLayout()); + + if (file != null && file.exists()) { + try { + image = ImageIO.read(file); + if (image != null) { + JLabel imageLabel = new JLabel(new ImageIcon(image)); + JScrollPane scrollPane = new JScrollPane(imageLabel); + contentPanel.add(scrollPane, BorderLayout.CENTER); + } else { + errorMessage = "Unable to read image file: unsupported format"; + showError(); + } + } catch (IOException e) { + errorMessage = "Error reading image: " + e.getMessage(); + showError(); + } + } else { + errorMessage = "File not found or is not readable"; + showError(); + } + } + + private void showError() { + JTextArea errorArea = new JTextArea(errorMessage); + errorArea.setEditable(false); + errorArea.setLineWrap(true); + errorArea.setWrapStyleWord(true); + errorArea.setMargin(new Insets(10, 10, 10, 10)); + contentPanel.add(new JScrollPane(errorArea), BorderLayout.CENTER); + } + + @Override + public String getTitle() { + if (file == null) { + return "[Image]"; + } + String title = file.getName(); + if (image != null) { + title += String.format(" (%dx%d)", image.getWidth(), image.getHeight()); + } + return title; + } + + @Override + public String getFilePath() { + return file != null ? file.getAbsolutePath() : ""; + } + + @Override + public JComponent getContentPane() { + return contentPanel; + } + + @Override + public File getFile() { + return file; + } + + @Override + public String getShortFilename() { + return file != null ? file.getName() : "[Image]"; + } + + @Override + public boolean isModified() { + // Images are read-only + return false; + } + + @Override + public void setModified() { + // Images are read-only + } + + @Override + public ViewerType getViewerType() { + return ViewerType.IMAGE_VIEWER; + } + + @Override + public boolean hasPreview() { + return false; + } + + @Override + public void setViewMode(ViewMode viewMode) {} + + @Override + public ViewMode getViewMode() { + return ViewMode.DEFAULT; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/MarkdownHtmlSupport.java b/app/src/main/java/com/basic4gl/desktop/content/MarkdownHtmlSupport.java new file mode 100644 index 00000000..50cbcf46 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/MarkdownHtmlSupport.java @@ -0,0 +1,34 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.MainWindow; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public final class MarkdownHtmlSupport { + + private static final String DOCS_MARKDOWN_STYLESHEET_RESOURCE = "/css/docs-markdown.css"; + + private MarkdownHtmlSupport() {} + + public static String buildMarkdownDocumentHtml(String bodyHtml) { + String stylesheetText = readTextResource(DOCS_MARKDOWN_STYLESHEET_RESOURCE); + return "" + + bodyHtml + + ""; + } + + private static String readTextResource(String resourcePath) { + try (InputStream input = MainWindow.class.getResourceAsStream(resourcePath)) { + if (input == null) { + return ""; + } + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException ex) { + System.err.println("Unable to load resource " + resourcePath + ": " + ex.getMessage()); + return ""; + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java b/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java new file mode 100644 index 00000000..0439fcda --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java @@ -0,0 +1,84 @@ +package com.basic4gl.desktop.content; + +import static com.basic4gl.desktop.util.HtmlUtil.markdownToHtml; + +import com.basic4gl.desktop.editor.IFileEditorActionListener; +import com.basic4gl.desktop.editor.IToggleBreakpointListener; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.content.FileViewerException; +import com.basic4gl.desktop.util.IFileManager; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.swing.*; +import org.fife.ui.rsyntaxtextarea.LinkGenerator; +import org.fife.ui.rtextarea.SearchContext; + +public class MarkdownViewer extends HtmlViewer { + + private static final String DOCS_EXPLORER_TAB_TITLE = "Explorer"; + + public MarkdownViewer() { + super(); + } + + public MarkdownViewer(PluginContext pluginContext, File file) { + super(); + try { + loadFile(pluginContext, file.toPath()); + } catch (FileViewerException ex) { + pluginContext.dialogs().showDialog("Unable to load markdown file: " + ex.getMessage()); + } + } + + public MarkdownViewer( + PluginContext pluginContext, + File file, + IFileEditorActionListener actionListener, + IFileManager fileManager, + IToggleBreakpointListener toggleBreakpointListener, + LinkGenerator linkGenerator, + SearchContext searchContext) { + super(pluginContext, file, actionListener, fileManager, toggleBreakpointListener, linkGenerator, searchContext); + } + + @Override + public void loadFile(PluginContext context, Path path) throws FileViewerException { + file = path.toFile(); + + try { + String markdown = Files.readString(path, StandardCharsets.UTF_8); + source = markdown; + String html = renderPreviewHtml(markdown); + + loadHtmlContent(html); + + } catch (IOException ex) { + context.dialogs().showDialog("Unable to open markdown file: " + ex.getMessage()); + } catch (Throwable ex) { + context.dialogs().showDialog("Unable to render markdown file: " + ex.getMessage()); + } + } + + @Override + public String getTitle() { + return file != null ? file.getName() : "[Markdown]"; + } + + @Override + public String getShortFilename() { + return file != null ? file.getName() : "[Markdown]"; + } + + @Override + public ViewerType getViewerType() { + return ViewerType.MARKDOWN_VIEWER; + } + + @Override + protected String renderPreviewHtml(String source) { + return MarkdownHtmlSupport.buildMarkdownDocumentHtml(markdownToHtml(source == null ? "" : source)); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewerProvider.java b/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewerProvider.java new file mode 100644 index 00000000..876dfbe2 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewerProvider.java @@ -0,0 +1,23 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerMetadata; +import com.basic4gl.desktop.spi.content.FileViewerProvider; + +public class MarkdownViewerProvider implements FileViewerProvider { + + private static final FileViewerMetadata METADATA = new FileViewerMetadata( + "Markdown Viewer", "1.0.0", "Simple viewer for Markdown files", new String[] {".md"}, new String[] { + "text/markdown" + }); + + @Override + public FileViewer createViewer() { + return new SimpleTextViewer(); + } + + @Override + public FileViewerMetadata getMetadata() { + return METADATA; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/SimpleTextViewer.java b/app/src/main/java/com/basic4gl/desktop/content/SimpleTextViewer.java new file mode 100644 index 00000000..62d6841c --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/SimpleTextViewer.java @@ -0,0 +1,112 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerException; +import java.awt.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.swing.*; + +/** + * Simple text file viewer + * + * Features: + * - Display plain text files + * - Read-only display + * - Syntax-agnostic (works with any text format) + */ +public class SimpleTextViewer implements FileViewer { + + private final JTextArea textArea; + private final JScrollPane scrollPane; + private Path currentPath; + + public SimpleTextViewer() { + this.textArea = new JTextArea(); + this.textArea.setEditable(false); + this.textArea.setFont(new Font("Monospaced", Font.PLAIN, 12)); + this.textArea.setBackground(Color.WHITE); + this.textArea.setForeground(Color.BLACK); + this.textArea.setMargin(new Insets(5, 5, 5, 5)); + this.textArea.setLineWrap(false); + + this.scrollPane = new JScrollPane(textArea); + this.scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS); + } + + @Override + public void loadFile(PluginContext context, Path path) throws FileViewerException { + try { + if (!Files.exists(path)) { + throw new FileViewerException("File not found: " + path); + } + + // Limit file size to prevent memory issues (50 MB) + long fileSize = Files.size(path); + if (fileSize > 50 * 1024 * 1024) { + throw new FileViewerException("File too large to display: " + fileSize + " bytes"); + } + + String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + textArea.setText(content); + textArea.setCaretPosition(0); + currentPath = path; + + } catch (FileViewerException e) { + throw e; + } catch (Exception e) { + throw new FileViewerException("Failed to load text file: " + e.getMessage(), e); + } + } + + @Override + public JComponent getComponent() { + return scrollPane; + } + + @Override + public boolean canHandle(String filename, String mimeType) { + String lower = (filename != null ? filename.toLowerCase() : ""); + String mime = (mimeType != null ? mimeType.toLowerCase() : ""); + + // Check by extension + if (lower.endsWith(".txt") + || lower.endsWith(".json") + || lower.endsWith(".xml") + || lower.endsWith(".html") + || lower.endsWith(".htm") + || lower.endsWith(".css") + || lower.endsWith(".java") + || lower.endsWith(".cpp") + || lower.endsWith(".c") + || lower.endsWith(".h") + || lower.endsWith(".py") + || lower.endsWith(".js") + || lower.endsWith(".md") + || lower.endsWith(".config") + || lower.endsWith(".log")) { + return true; + } + + // Check by MIME type + return mime.startsWith("text/"); + } + + @Override + public String getName() { + return "Text Viewer"; + } + + @Override + public String getVersion() { + return "1.0.0"; + } + + @Override + public void dispose() { + textArea.setText(""); + currentPath = null; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/SimpleTextViewerProvider.java b/app/src/main/java/com/basic4gl/desktop/content/SimpleTextViewerProvider.java new file mode 100644 index 00000000..e538844c --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/SimpleTextViewerProvider.java @@ -0,0 +1,33 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerMetadata; +import com.basic4gl.desktop.spi.content.FileViewerProvider; + +/** + * Provider for SimpleTextViewer + */ +public class SimpleTextViewerProvider implements FileViewerProvider { + + private static final FileViewerMetadata METADATA = new FileViewerMetadata( + "Text Viewer", + "1.0.0", + "Simple viewer for text-based files", + new String[] { + ".txt", ".json", ".xml", ".html", ".htm", ".css", ".java", ".cpp", ".c", ".h", ".py", ".js", ".md", + ".log" + }, + new String[] { + "text/plain", "text/html", "text/css", "application/json", "application/xml", "text/x-java-source" + }); + + @Override + public FileViewer createViewer() { + return new SimpleTextViewer(); + } + + @Override + public FileViewerMetadata getMetadata() { + return METADATA; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/TemplateInstantiator.java b/app/src/main/java/com/basic4gl/desktop/content/TemplateInstantiator.java new file mode 100644 index 00000000..41127fbf --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/TemplateInstantiator.java @@ -0,0 +1,45 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.content.catalog.TemplateCatalogEntry; +import com.basic4gl.desktop.spi.content.ContentPaths; +import com.basic4gl.desktop.spi.content.TemplateCreationRequest; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class TemplateInstantiator { + + public Optional instantiate( + TemplateCatalogEntry entry, Path destination, String projectName, Map variables) + throws IOException { + Objects.requireNonNull(entry, "entry"); + Path safeDestination = Objects.requireNonNull(destination, "destination") + .toAbsolutePath() + .normalize(); + if (Files.exists(safeDestination)) { + throw new FileAlreadyExistsException(safeDestination.toString()); + } + + entry.provider() + .instantiate( + entry.descriptor().id(), new TemplateCreationRequest(safeDestination, projectName, variables)); + + String entryPoint = entry.descriptor().entryPoint(); + if (entryPoint == null || entryPoint.isBlank()) { + return Optional.empty(); + } + String normalizedEntryPoint = ContentPaths.normalize(entryPoint); + Path entryPointPath = safeDestination + .resolve(normalizedEntryPoint.replace( + "/", safeDestination.getFileSystem().getSeparator())) + .normalize(); + if (!entryPointPath.startsWith(safeDestination)) { + throw new IOException("Template entry point escapes destination: " + entryPoint); + } + return Optional.of(entryPointPath); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/TextFileViewer.java b/app/src/main/java/com/basic4gl/desktop/content/TextFileViewer.java new file mode 100644 index 00000000..f529bb0b --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/TextFileViewer.java @@ -0,0 +1,131 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.editor.IFileEditorActionListener; +import com.basic4gl.desktop.editor.IFileViewer; +import com.basic4gl.desktop.editor.IToggleBreakpointListener; +import com.basic4gl.desktop.util.IFileManager; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import javax.swing.*; +import org.fife.ui.rsyntaxtextarea.LinkGenerator; +import org.fife.ui.rtextarea.SearchContext; + +/** + * Text file viewer that wraps the existing FileEditor functionality. + * This is the primary viewer for code files, text files, markdown, and any other text-based content. + */ +public class TextFileViewer implements IFileViewer { + private final FileEditor editor; + + public TextFileViewer(FileEditor editor) { + this.editor = editor; + } + + /** + * Creates a new text file viewer for an unsaved file. + */ + public TextFileViewer( + IFileEditorActionListener actionListener, + IFileManager fileManager, + IToggleBreakpointListener toggleBreakpointListener, + LinkGenerator linkGenerator, + SearchContext searchContext) { + this.editor = + new FileEditor(actionListener, fileManager, toggleBreakpointListener, linkGenerator, searchContext); + } + + /** + * Creates a new text file viewer and loads the specified file. + */ + public TextFileViewer( + File file, + IFileEditorActionListener actionListener, + IFileManager fileManager, + IToggleBreakpointListener toggleBreakpointListener, + LinkGenerator linkGenerator, + SearchContext searchContext) { + this.editor = + new FileEditor(actionListener, fileManager, toggleBreakpointListener, linkGenerator, searchContext); + + if (file != null && file.exists()) { + try { + FileReader fr = new FileReader(file); + editor.filePath = file.getAbsolutePath(); + editor.fileName = file.getName(); + editor.editorPane.read(fr, null); + fr.close(); + editor.isSaved = true; + } catch (IOException e) { + e.printStackTrace(); + JOptionPane.showMessageDialog( + null, "Could not read file: " + e.getMessage(), "File Error", JOptionPane.ERROR_MESSAGE); + } + editor.editorPane.discardAllEdits(); // Otherwise 'undo' will clear the text area after loading + } else if (file != null) { + editor.filePath = file.getAbsolutePath(); + editor.fileName = file.getName(); + editor.isSaved = false; + } + } + + /** + * Gets the underlying FileEditor instance for compatibility with existing code + */ + public FileEditor getFileEditor() { + return editor; + } + + @Override + public String getTitle() { + return editor.getTitle(); + } + + @Override + public String getFilePath() { + return editor.getFilePath(); + } + + @Override + public JComponent getContentPane() { + return editor.getContentPane(); + } + + @Override + public File getFile() { + return editor.getFile(); + } + + @Override + public String getShortFilename() { + return editor.getShortFilename(); + } + + @Override + public boolean isModified() { + return editor.isModified(); + } + + @Override + public void setModified() { + editor.setModified(); + } + + @Override + public ViewerType getViewerType() { + return ViewerType.TEXT_EDITOR; + } + + @Override + public boolean hasPreview() { + return false; + } + + @Override + public void setViewMode(ViewMode viewMode) {} + + @Override + public ViewMode getViewMode() { + return ViewMode.DEFAULT; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentBrowseNode.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentBrowseNode.java new file mode 100644 index 00000000..214cbc20 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentBrowseNode.java @@ -0,0 +1,54 @@ +package com.basic4gl.desktop.content.catalog; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public final class ContentBrowseNode { + + private final String name; + private final List children = new ArrayList<>(); + private final List items = new ArrayList<>(); + + public ContentBrowseNode(String name) { + this.name = name; + } + + public String name() { + return name; + } + + public List children() { + return List.copyOf(children); + } + + public List items() { + return List.copyOf(items); + } + + void add(List categoryPath, ContentPanelItem item) { + if (categoryPath.isEmpty()) { + items.add(item); + sort(); + return; + } + String childName = categoryPath.get(0); + ContentBrowseNode child = children.stream() + .filter(node -> node.name.equals(childName)) + .findFirst() + .orElseGet(() -> { + ContentBrowseNode node = new ContentBrowseNode(childName); + children.add(node); + return node; + }); + child.add(categoryPath.subList(1, categoryPath.size()), item); + sort(); + } + + private void sort() { + children.sort(Comparator.comparing(ContentBrowseNode::name, String.CASE_INSENSITIVE_ORDER)); + items.sort( + Comparator.comparing((ContentPanelItem item) -> item.content().sortOrder()) + .thenComparing(ContentPanelItem::title, String.CASE_INSENSITIVE_ORDER)); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalog.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalog.java new file mode 100644 index 00000000..f14d5d7b --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalog.java @@ -0,0 +1,223 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.ContentRegistration; +import com.basic4gl.desktop.spi.content.DocumentDescriptor; +import com.basic4gl.desktop.spi.content.DocumentProvider; +import com.basic4gl.desktop.spi.content.TemplateDescriptor; +import com.basic4gl.desktop.spi.content.TemplateProvider; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class ContentCatalog { + + private final Map> documentProviders = new LinkedHashMap<>(); + private final Map> templateProviders = new LinkedHashMap<>(); + private final Set providerKeys = new HashSet<>(); + private final List listeners = new ArrayList<>(); + + public synchronized ContentRegistration registerDocumentProvider( + String pluginId, String pluginDisplayName, DocumentProvider provider) { + Objects.requireNonNull(provider, "provider"); + String providerId = requireNonBlank(provider.id(), "provider.id"); + String providerVersion = requireNonBlank(provider.version(), "provider.version"); + String providerKey = providerKey(pluginId, providerId); + rejectDuplicateProvider(providerKey); + + Collection descriptors = requireIndex(provider.getIndex(), "document index"); + List entries = new ArrayList<>(); + Set itemIds = new HashSet<>(); + for (DocumentDescriptor descriptor : descriptors) { + Objects.requireNonNull(descriptor, "document descriptor"); + if (!itemIds.add(descriptor.id())) { + throw new IllegalArgumentException( + "Duplicate document ID in provider " + providerId + ": " + descriptor.id()); + } + entries.add(new DocumentCatalogEntry( + new ContentGlobalId(pluginId, providerId, descriptor.id()), + pluginDisplayName, + providerVersion, + descriptor, + provider)); + } + + providerKeys.add(providerKey); + documentProviders.put(providerKey, List.copyOf(entries)); + warnForMissingDocumentRelationships(pluginId, entries); + notifyListeners(); + return new Registration(providerKey); + } + + public synchronized ContentRegistration registerTemplateProvider( + String pluginId, String pluginDisplayName, TemplateProvider provider) { + Objects.requireNonNull(provider, "provider"); + String providerId = requireNonBlank(provider.id(), "provider.id"); + String providerVersion = requireNonBlank(provider.version(), "provider.version"); + String providerKey = providerKey(pluginId, providerId); + rejectDuplicateProvider(providerKey); + + Collection descriptors = requireIndex(provider.getIndex(), "template index"); + List entries = new ArrayList<>(); + Set itemIds = new HashSet<>(); + for (TemplateDescriptor descriptor : descriptors) { + Objects.requireNonNull(descriptor, "template descriptor"); + if (!itemIds.add(descriptor.id())) { + throw new IllegalArgumentException( + "Duplicate template ID in provider " + providerId + ": " + descriptor.id()); + } + entries.add(new TemplateCatalogEntry( + new ContentGlobalId(pluginId, providerId, descriptor.id()), + pluginDisplayName, + providerVersion, + descriptor, + provider)); + } + + providerKeys.add(providerKey); + templateProviders.put(providerKey, List.copyOf(entries)); + warnForMissingTemplateRelationships(pluginId, entries); + notifyListeners(); + return new Registration(providerKey); + } + + public synchronized List documents() { + return documentProviders.values().stream().flatMap(List::stream).toList(); + } + + public synchronized List templates() { + return templateProviders.values().stream().flatMap(List::stream).toList(); + } + + public synchronized void addListener(ContentCatalogListener listener) { + listeners.add(Objects.requireNonNull(listener, "listener")); + } + + public synchronized void removeListener(ContentCatalogListener listener) { + listeners.remove(listener); + } + + public void unregisterPlugin(String pluginId) { + requireNonBlank(pluginId, "pluginId"); + boolean removed; + synchronized (this) { + removed = providerKeys.removeIf(providerKey -> providerKey.startsWith(pluginId + ":")); + documentProviders.keySet().removeIf(providerKey -> providerKey.startsWith(pluginId + ":")); + templateProviders.keySet().removeIf(providerKey -> providerKey.startsWith(pluginId + ":")); + } + if (removed) { + notifyListeners(); + } + } + + private void unregisterProvider(String providerKey) { + synchronized (this) { + if (!providerKeys.remove(providerKey)) { + return; + } + documentProviders.remove(providerKey); + templateProviders.remove(providerKey); + } + notifyListeners(); + } + + private void rejectDuplicateProvider(String providerKey) { + if (providerKeys.contains(providerKey)) { + throw new IllegalArgumentException("Duplicate content provider ID for plugin: " + providerKey); + } + } + + private static Collection requireIndex(Collection index, String description) { + if (index == null) { + throw new IllegalArgumentException(description + " must not be null"); + } + return index; + } + + private static String providerKey(String pluginId, String providerId) { + return requireNonBlank(pluginId, "pluginId") + ":" + providerId; + } + + private static String requireNonBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be null or blank"); + } + return value; + } + + private void warnForMissingDocumentRelationships(String pluginId, List entries) { + Set templateIds = templateIds(pluginId); + for (DocumentCatalogEntry entry : entries) { + for (String templateId : entry.descriptor().relatedTemplateIds()) { + if (!templateIds.contains(templateId)) { + System.err.println("Missing related template '" + templateId + "' for document '" + + entry.descriptor().id() + "'"); + } + } + } + } + + private void warnForMissingTemplateRelationships(String pluginId, List entries) { + Set documentIds = documentIds(pluginId); + for (TemplateCatalogEntry entry : entries) { + for (String documentId : entry.descriptor().relatedDocumentIds()) { + if (!documentIds.contains(documentId)) { + System.err.println("Missing related document '" + documentId + "' for template '" + + entry.descriptor().id() + "'"); + } + } + } + } + + private Set documentIds(String pluginId) { + Set ids = new HashSet<>(); + for (DocumentCatalogEntry entry : documents()) { + if (entry.globalId().pluginId().equals(pluginId)) { + ids.add(entry.descriptor().id()); + } + } + return ids; + } + + private Set templateIds(String pluginId) { + Set ids = new HashSet<>(); + for (TemplateCatalogEntry entry : templates()) { + if (entry.globalId().pluginId().equals(pluginId)) { + ids.add(entry.descriptor().id()); + } + } + return ids; + } + + private void notifyListeners() { + List listenerSnapshot; + synchronized (this) { + listenerSnapshot = List.copyOf(listeners); + } + for (ContentCatalogListener listener : listenerSnapshot) { + listener.contentCatalogChanged(); + } + } + + private final class Registration implements ContentRegistration { + private final String providerKey; + private boolean closed; + + private Registration(String providerKey) { + this.providerKey = providerKey; + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + unregisterProvider(providerKey); + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalogListener.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalogListener.java new file mode 100644 index 00000000..410f3bce --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalogListener.java @@ -0,0 +1,6 @@ +package com.basic4gl.desktop.content.catalog; + +public interface ContentCatalogListener { + + void contentCatalogChanged(); +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentGlobalId.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentGlobalId.java new file mode 100644 index 00000000..578b0dd3 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentGlobalId.java @@ -0,0 +1,24 @@ +package com.basic4gl.desktop.content.catalog; + +import java.util.Objects; + +public record ContentGlobalId(String pluginId, String providerId, String itemId) { + + public ContentGlobalId { + pluginId = requireNonBlank(pluginId, "pluginId"); + providerId = requireNonBlank(providerId, "providerId"); + itemId = requireNonBlank(itemId, "itemId"); + } + + public String value() { + return pluginId + ":" + providerId + ":" + itemId; + } + + private static String requireNonBlank(String value, String fieldName) { + Objects.requireNonNull(value, fieldName); + if (value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelItem.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelItem.java new file mode 100644 index 00000000..c7b609e5 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelItem.java @@ -0,0 +1,29 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.IndexedContent; +import java.util.List; +import java.util.Set; + +public record ContentPanelItem( + String globalId, + String title, + String subtitle, + String description, + List categoryPath, + Set tags, + boolean template, + IndexedContent content) { + + public String displayName() { + return title == null ? "" : title; + } + + public String displaySubtitle() { + return subtitle == null ? "" : subtitle; + } + + @Override + public String toString() { + return displayName(); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelModel.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelModel.java new file mode 100644 index 00000000..1629d74f --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelModel.java @@ -0,0 +1,169 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.DocumentDescriptor; +import com.basic4gl.desktop.spi.content.IndexedContent; +import com.basic4gl.desktop.spi.content.TemplateDescriptor; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.function.Function; + +public final class ContentPanelModel { + + private final ContentSearchIndex searchIndex = new ContentSearchIndex(); + + public List scopes(ContentCatalog catalog) { + Set tags = new LinkedHashSet<>(); + catalog.documents().stream() + .flatMap(entry -> entry.descriptor().tags().stream()) + .map(String::trim) + .filter(tag -> !tag.isBlank()) + .map(tag -> tag.toLowerCase(Locale.ROOT)) + .forEach(tags::add); + catalog.templates().stream() + .flatMap(entry -> entry.descriptor().tags().stream()) + .map(String::trim) + .filter(tag -> !tag.isBlank()) + .map(tag -> tag.toLowerCase(Locale.ROOT)) + .forEach(tags::add); + + List scopes = new ArrayList<>(); + scopes.add(ContentScope.ALL); + tags.stream() + .map(ContentScope::forTag) + .sorted(Comparator.comparing(ContentScope::displayName, String.CASE_INSENSITIVE_ORDER)) + .forEach(scopes::add); + return List.copyOf(scopes); + } + + public List items(ContentCatalog catalog, ContentScope scope, String query) { + List documents = filteredDocuments(catalog.documents(), scope); + List templates = filteredTemplates(catalog.templates(), scope); + if (query != null && !query.isBlank()) { + return searchIndex.search(documents, templates, query).stream() + .map(this::toItem) + .toList(); + } + + List items = new ArrayList<>(); + documents.stream().map(this::toItem).forEach(items::add); + templates.stream().map(this::toItem).forEach(items::add); + items.sort((left, right) -> { + int sort = + Integer.compare(left.content().sortOrder(), right.content().sortOrder()); + if (sort != 0) { + return sort; + } + return left.title().compareToIgnoreCase(right.title()); + }); + return List.copyOf(items); + } + + public ContentBrowseNode browse(ContentCatalog catalog, ContentScope scope) { + return browse(catalog, scope, ContentPanelItem::categoryPath); + } + + public ContentBrowseNode browse( + ContentCatalog catalog, ContentScope scope, Function> categoryPathProvider) { + ContentBrowseNode root = new ContentBrowseNode(scope.displayName()); + for (ContentPanelItem item : items(catalog, scope, "")) { + root.add(categoryPathProvider.apply(item), item); + } + return root; + } + + public ContentSelectionSummary summary(ContentPanelItem item) { + IndexedContent content = item.content(); + String category = String.join(" / ", content.categoryPath()); + if (content instanceof DocumentDescriptor descriptor) { + String kind = tagLabels(descriptor.tags(), "Document"); + return new ContentSelectionSummary( + descriptor.title(), + kind, + descriptor.description(), + category, + descriptor.relatedTemplateIds(), + "Open"); + } + if (content instanceof TemplateDescriptor descriptor) { + String kind = tagLabels(descriptor.tags(), "Template"); + String action = descriptor.tags().isEmpty() ? "Create Program" : "Open " + kind; + return new ContentSelectionSummary( + descriptor.title(), + kind, + descriptor.description(), + category, + descriptor.relatedDocumentIds(), + action); + } + return new ContentSelectionSummary( + content.title(), "Content", content.description(), category, List.of(), "Open"); + } + + private List filteredDocuments(List documents, ContentScope scope) { + return documents.stream().filter(entry -> scope.matches(entry.descriptor())).toList(); + } + + private List filteredTemplates(List templates, ContentScope scope) { + return templates.stream().filter(entry -> scope.matches(entry.descriptor())).toList(); + } + + private ContentPanelItem toItem(ContentSearchResult result) { + IndexedContent content = result.content(); + return new ContentPanelItem( + result.globalId(), + content.title(), + subtitle(content, result.template()), + content.description(), + content.categoryPath(), + content.tags(), + result.template(), + content); + } + + private ContentPanelItem toItem(DocumentCatalogEntry entry) { + DocumentDescriptor descriptor = entry.descriptor(); + return new ContentPanelItem( + entry.globalId().value(), + descriptor.title(), + subtitle(descriptor, false), + descriptor.description(), + descriptor.categoryPath(), + descriptor.tags(), + false, + descriptor); + } + + private ContentPanelItem toItem(TemplateCatalogEntry entry) { + TemplateDescriptor descriptor = entry.descriptor(); + return new ContentPanelItem( + entry.globalId().value(), + descriptor.title(), + subtitle(descriptor, true), + descriptor.description(), + descriptor.categoryPath(), + descriptor.tags(), + true, + descriptor); + } + + private String subtitle(IndexedContent content, boolean template) { + String kind = tagLabels(content.tags(), template ? "Template" : "Document"); + String category = String.join(" / ", content.categoryPath()); + return category.isBlank() ? kind : kind + " · " + category; + } + + private String tagLabels(Set tags, String fallback) { + if (tags.isEmpty()) { + return fallback; + } + return tags.stream() + .map(ContentScope::labelForTag) + .sorted(String.CASE_INSENSITIVE_ORDER) + .reduce((left, right) -> left + ", " + right) + .orElse(fallback); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentScope.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentScope.java new file mode 100644 index 00000000..1bc1be7b --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentScope.java @@ -0,0 +1,52 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.IndexedContent; +import java.util.Locale; +import java.util.Set; + +public record ContentScope(String tag, String displayName) { + + public static final ContentScope ALL = new ContentScope(null, "All Documentation"); + + public ContentScope { + tag = tag == null || tag.isBlank() ? null : tag.trim().toLowerCase(Locale.ROOT); + displayName = displayName == null || displayName.isBlank() ? labelForTag(tag) : displayName.trim(); + } + + public static ContentScope forTag(String tag) { + return new ContentScope(tag, labelForTag(tag)); + } + + public boolean all() { + return tag == null; + } + + public boolean matches(IndexedContent content) { + return all() || hasTag(content.tags()); + } + + public boolean hasTag(Set tags) { + return tag != null && tags.stream().anyMatch(value -> tag.equals(value.toLowerCase(Locale.ROOT))); + } + + public static String labelForTag(String tag) { + if (tag == null || tag.isBlank()) { + return ""; + } + String[] words = tag.trim().replace('_', '-').split("-+"); + StringBuilder label = new StringBuilder(); + for (String word : words) { + if (word.isBlank()) { + continue; + } + if (!label.isEmpty()) { + label.append(' '); + } + label.append(Character.toUpperCase(word.charAt(0))); + if (word.length() > 1) { + label.append(word.substring(1).toLowerCase(Locale.ROOT)); + } + } + return label.toString(); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchIndex.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchIndex.java new file mode 100644 index 00000000..9adfac85 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchIndex.java @@ -0,0 +1,63 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.IndexedContent; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +public final class ContentSearchIndex { + + public List search( + List documents, List templates, String query) { + String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); + List results = new ArrayList<>(); + for (DocumentCatalogEntry entry : documents) { + int score = score(entry.descriptor(), entry.pluginDisplayName(), normalizedQuery); + if (score > 0) { + results.add(new ContentSearchResult(entry.globalId().value(), entry.descriptor(), false, score)); + } + } + for (TemplateCatalogEntry entry : templates) { + int score = score(entry.descriptor(), entry.pluginDisplayName(), normalizedQuery); + if (score > 0) { + results.add(new ContentSearchResult(entry.globalId().value(), entry.descriptor(), true, score)); + } + } + results.sort(Comparator.comparingInt(ContentSearchResult::score) + .reversed() + .thenComparing(result -> result.content().sortOrder()) + .thenComparing(result -> result.content().title(), String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(results); + } + + private int score(IndexedContent content, String pluginDisplayName, String query) { + if (query.isBlank()) { + return 1; + } + String title = content.title().toLowerCase(Locale.ROOT); + if (title.equals(query)) { + return 100; + } + if (title.startsWith(query)) { + return 80; + } + if (title.contains(query)) { + return 60; + } + if (content.tags().stream().anyMatch(tag -> tag.toLowerCase(Locale.ROOT).equals(query))) { + return 50; + } + if (content.description().toLowerCase(Locale.ROOT).contains(query)) { + return 30; + } + if (content.categoryPath().stream() + .anyMatch(category -> category.toLowerCase(Locale.ROOT).contains(query))) { + return 25; + } + if (pluginDisplayName.toLowerCase(Locale.ROOT).contains(query)) { + return 10; + } + return 0; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchResult.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchResult.java new file mode 100644 index 00000000..29646491 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchResult.java @@ -0,0 +1,5 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.IndexedContent; + +public record ContentSearchResult(String globalId, IndexedContent content, boolean template, int score) {} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSelectionSummary.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSelectionSummary.java new file mode 100644 index 00000000..d8aba563 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSelectionSummary.java @@ -0,0 +1,11 @@ +package com.basic4gl.desktop.content.catalog; + +import java.util.List; + +public record ContentSelectionSummary( + String title, + String kindLabel, + String description, + String category, + List relatedIds, + String primaryAction) {} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/DefaultContentService.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/DefaultContentService.java new file mode 100644 index 00000000..cb2d5588 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/DefaultContentService.java @@ -0,0 +1,37 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.ContentRegistration; +import com.basic4gl.desktop.spi.content.ContentService; +import com.basic4gl.desktop.spi.content.DocumentProvider; +import com.basic4gl.desktop.spi.content.TemplateProvider; +import java.util.Objects; + +public final class DefaultContentService implements ContentService { + + private final ContentCatalog catalog; + private final String pluginId; + private final String pluginDisplayName; + + public DefaultContentService(ContentCatalog catalog, String pluginId, String pluginDisplayName) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + this.pluginId = requireNonBlank(pluginId, "pluginId"); + this.pluginDisplayName = pluginDisplayName == null ? "" : pluginDisplayName; + } + + @Override + public ContentRegistration registerDocumentProvider(DocumentProvider provider) { + return catalog.registerDocumentProvider(pluginId, pluginDisplayName, provider); + } + + @Override + public ContentRegistration registerTemplateProvider(TemplateProvider provider) { + return catalog.registerTemplateProvider(pluginId, pluginDisplayName, provider); + } + + private static String requireNonBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be null or blank"); + } + return value; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/DocumentCatalogEntry.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/DocumentCatalogEntry.java new file mode 100644 index 00000000..96a8a8f0 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/DocumentCatalogEntry.java @@ -0,0 +1,21 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.DocumentDescriptor; +import com.basic4gl.desktop.spi.content.DocumentProvider; +import java.util.Objects; + +public record DocumentCatalogEntry( + ContentGlobalId globalId, + String pluginDisplayName, + String providerVersion, + DocumentDescriptor descriptor, + DocumentProvider provider) { + + public DocumentCatalogEntry { + globalId = Objects.requireNonNull(globalId, "globalId"); + pluginDisplayName = pluginDisplayName == null ? "" : pluginDisplayName; + providerVersion = providerVersion == null ? "" : providerVersion; + descriptor = Objects.requireNonNull(descriptor, "descriptor"); + provider = Objects.requireNonNull(provider, "provider"); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/TemplateCatalogEntry.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/TemplateCatalogEntry.java new file mode 100644 index 00000000..202fb11b --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/TemplateCatalogEntry.java @@ -0,0 +1,21 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.TemplateDescriptor; +import com.basic4gl.desktop.spi.content.TemplateProvider; +import java.util.Objects; + +public record TemplateCatalogEntry( + ContentGlobalId globalId, + String pluginDisplayName, + String providerVersion, + TemplateDescriptor descriptor, + TemplateProvider provider) { + + public TemplateCatalogEntry { + globalId = Objects.requireNonNull(globalId, "globalId"); + pluginDisplayName = pluginDisplayName == null ? "" : pluginDisplayName; + providerVersion = providerVersion == null ? "" : providerVersion; + descriptor = Objects.requireNonNull(descriptor, "descriptor"); + provider = Objects.requireNonNull(provider, "provider"); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/ContentNavigationHandler.java b/app/src/main/java/com/basic4gl/desktop/content/render/ContentNavigationHandler.java new file mode 100644 index 00000000..7b11ef6c --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/ContentNavigationHandler.java @@ -0,0 +1,18 @@ +package com.basic4gl.desktop.content.render; + +import java.net.URI; + +public interface ContentNavigationHandler { + + ContentNavigationHandler NO_OP = new ContentNavigationHandler() { + @Override + public void navigateTo(String normalizedPath) {} + + @Override + public void openExternal(URI uri) {} + }; + + void navigateTo(String normalizedPath); + + void openExternal(URI uri); +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderRequest.java b/app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderRequest.java new file mode 100644 index 00000000..92ff7718 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderRequest.java @@ -0,0 +1,21 @@ +package com.basic4gl.desktop.content.render; + +import com.basic4gl.desktop.spi.content.ContentDocument; +import java.nio.file.Path; +import java.util.Objects; + +public record ContentRenderRequest( + ContentDocument document, + Path materializedRoot, + String currentPath, + ContentNavigationHandler navigationHandler) { + + public ContentRenderRequest { + document = Objects.requireNonNull(document, "document"); + materializedRoot = Objects.requireNonNull(materializedRoot, "materializedRoot") + .toAbsolutePath() + .normalize(); + currentPath = currentPath == null || currentPath.isBlank() ? document.entryPath() : currentPath; + navigationHandler = navigationHandler == null ? ContentNavigationHandler.NO_OP : navigationHandler; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderer.java b/app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderer.java new file mode 100644 index 00000000..24aaabfd --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderer.java @@ -0,0 +1,11 @@ +package com.basic4gl.desktop.content.render; + +import java.io.IOException; +import javax.swing.JComponent; + +public interface ContentRenderer { + + boolean supports(String mediaType); + + JComponent render(ContentRenderRequest request) throws IOException; +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/HtmlContentRenderer.java b/app/src/main/java/com/basic4gl/desktop/content/render/HtmlContentRenderer.java new file mode 100644 index 00000000..b99ca6fc --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/HtmlContentRenderer.java @@ -0,0 +1,18 @@ +package com.basic4gl.desktop.content.render; + +import java.io.IOException; +import javax.swing.JComponent; + +public final class HtmlContentRenderer implements ContentRenderer { + + @Override + public boolean supports(String mediaType) { + return RendererSupport.mediaTypeEquals(mediaType, "text/html"); + } + + @Override + public JComponent render(ContentRenderRequest request) throws IOException { + String html = RendererSupport.readString(request.materializedRoot(), request.currentPath()); + return RendererSupport.htmlComponent(html, request.navigationHandler()); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/MarkdownContentRenderer.java b/app/src/main/java/com/basic4gl/desktop/content/render/MarkdownContentRenderer.java new file mode 100644 index 00000000..437a8005 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/MarkdownContentRenderer.java @@ -0,0 +1,156 @@ +package com.basic4gl.desktop.content.render; + +import com.basic4gl.desktop.content.MarkdownHtmlSupport; +import com.basic4gl.desktop.spi.content.ContentPaths; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import javax.swing.JComponent; +import org.commonmark.node.Image; +import org.commonmark.node.Link; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.AttributeProvider; +import org.commonmark.renderer.html.AttributeProviderContext; +import org.commonmark.renderer.html.AttributeProviderFactory; +import org.commonmark.renderer.html.HtmlRenderer; + +public final class MarkdownContentRenderer implements ContentRenderer { + + private static final long MAX_IMAGE_BYTES = 10L * 1024L * 1024L; + private static final int MAX_IMAGE_DIMENSION = 8192; + + @Override + public boolean supports(String mediaType) { + return RendererSupport.mediaTypeEquals(mediaType, "text/markdown") + || RendererSupport.mediaTypeEquals(mediaType, "text/x-markdown"); + } + + @Override + public JComponent render(ContentRenderRequest request) throws IOException { + String markdown = RendererSupport.readString(request.materializedRoot(), request.currentPath()); + Parser parser = Parser.builder().build(); + HtmlRenderer renderer = HtmlRenderer.builder() + .escapeHtml(true) + .attributeProviderFactory( + new ContentAttributeProviderFactory(request.materializedRoot(), request.currentPath())) + .build(); + Node document = parser.parse(markdown); + String html = MarkdownHtmlSupport.buildMarkdownDocumentHtml( + "" + renderer.render(document) + ""); + return RendererSupport.htmlComponent(html, request.navigationHandler()); + } + + private record ContentAttributeProviderFactory(Path materializedRoot, String currentPath) + implements AttributeProviderFactory { + @Override + public AttributeProvider create(AttributeProviderContext context) { + return new ContentAttributeProvider(materializedRoot, currentPath); + } + } + + private record ContentAttributeProvider(Path materializedRoot, String currentPath) implements AttributeProvider { + @Override + public void setAttributes(Node node, String tagName, Map attributes) { + if (node instanceof Image) { + rewriteImage(attributes); + } else if (node instanceof Link) { + rewriteLink(attributes); + } + } + + private void rewriteImage(Map attributes) { + String destination = attributes.get("src"); + if (destination == null || isExternal(destination)) { + attributes.put("src", missingImagePlaceholder("Remote image blocked")); + return; + } + try { + String resolved = ContentPaths.resolve(currentPath, destination); + Path imagePath = RendererSupport.resolveMaterializedPath(materializedRoot, resolved); + if (!Files.isRegularFile(imagePath) || !isAllowedImage(imagePath)) { + attributes.put("src", missingImagePlaceholder("Image unavailable")); + return; + } + attributes.put("src", imagePath.toUri().toString()); + } catch (IOException | IllegalArgumentException ex) { + attributes.put("src", missingImagePlaceholder("Image unavailable")); + } + } + + private void rewriteLink(Map attributes) { + String destination = attributes.get("href"); + if (destination == null) { + return; + } + if (isExternal(destination)) { + return; + } + try { + String resolved = ContentPaths.resolve(currentPath, destination); + if (isNavigableDocument(resolved)) { + attributes.put("href", "content:" + resolved); + } else { + Path target = RendererSupport.resolveMaterializedPath(materializedRoot, resolved); + attributes.put("href", target.toUri().toString()); + } + } catch (IOException | IllegalArgumentException ex) { + attributes.put("href", "#"); + } + } + + private boolean isAllowedImage(Path imagePath) throws IOException { + if (Files.size(imagePath) > MAX_IMAGE_BYTES) { + return false; + } + try (ImageInputStream input = ImageIO.createImageInputStream(imagePath.toFile())) { + if (input == null) { + return true; + } + var readers = ImageIO.getImageReaders(input); + if (!readers.hasNext()) { + return true; + } + ImageReader reader = readers.next(); + try { + reader.setInput(input); + return reader.getWidth(0) <= MAX_IMAGE_DIMENSION && reader.getHeight(0) <= MAX_IMAGE_DIMENSION; + } finally { + reader.dispose(); + } + } + } + + private boolean isNavigableDocument(String path) { + String lower = path.toLowerCase(Locale.ROOT); + return lower.endsWith(".md") + || lower.endsWith(".markdown") + || lower.endsWith(".html") + || lower.endsWith(".htm") + || lower.endsWith(".txt"); + } + + private boolean isExternal(String destination) { + String lower = destination.toLowerCase(Locale.ROOT); + return lower.startsWith("http://") + || lower.startsWith("https://") + || lower.startsWith("file:") + || lower.startsWith("jar:"); + } + + private String missingImagePlaceholder(String message) { + String svg = "" + + "" + + "" + + message + ""; + return "data:image/svg+xml;utf8," + URLEncoder.encode(svg, StandardCharsets.UTF_8); + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/PlainTextContentRenderer.java b/app/src/main/java/com/basic4gl/desktop/content/render/PlainTextContentRenderer.java new file mode 100644 index 00000000..b1aa88d4 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/PlainTextContentRenderer.java @@ -0,0 +1,28 @@ +package com.basic4gl.desktop.content.render; + +import java.awt.BorderLayout; +import java.awt.Font; +import java.io.IOException; +import javax.swing.JComponent; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; + +public final class PlainTextContentRenderer implements ContentRenderer { + + @Override + public boolean supports(String mediaType) { + return RendererSupport.mediaTypeEquals(mediaType, "text/plain"); + } + + @Override + public JComponent render(ContentRenderRequest request) throws IOException { + JTextArea textArea = + new JTextArea(RendererSupport.readString(request.materializedRoot(), request.currentPath())); + textArea.setEditable(false); + textArea.setFont(new Font("Monospaced", Font.PLAIN, 12)); + JPanel panel = new JPanel(new BorderLayout()); + panel.add(new JScrollPane(textArea), BorderLayout.CENTER); + return panel; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/RendererSupport.java b/app/src/main/java/com/basic4gl/desktop/content/render/RendererSupport.java new file mode 100644 index 00000000..282ae817 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/RendererSupport.java @@ -0,0 +1,70 @@ +package com.basic4gl.desktop.content.render; + +import com.basic4gl.desktop.spi.content.ContentPaths; +import java.awt.BorderLayout; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import javax.swing.JComponent; +import javax.swing.JEditorPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.event.HyperlinkEvent; + +final class RendererSupport { + + private RendererSupport() {} + + static boolean mediaTypeEquals(String actual, String expected) { + String normalizedActual = + actual == null ? "" : actual.split(";", 2)[0].trim().toLowerCase(Locale.ROOT); + return normalizedActual.equals(expected); + } + + static String readString(Path materializedRoot, String currentPath) throws IOException { + Path path = resolveMaterializedPath(materializedRoot, currentPath); + return Files.readString(path, StandardCharsets.UTF_8); + } + + static Path resolveMaterializedPath(Path materializedRoot, String normalizedPath) throws IOException { + String safePath = ContentPaths.normalize(normalizedPath); + Path path = materializedRoot + .resolve(safePath.replace("/", materializedRoot.getFileSystem().getSeparator())) + .normalize(); + if (!path.startsWith(materializedRoot)) { + throw new IOException("Content path escapes materialized root: " + normalizedPath); + } + return path; + } + + static JComponent htmlComponent(String html, ContentNavigationHandler navigationHandler) { + JEditorPane pane = new JEditorPane(); + pane.setEditable(false); + pane.setContentType("text/html"); + pane.putClientProperty("basic4gl.content.html", html); + pane.addHyperlinkListener(event -> { + if (event.getEventType() != HyperlinkEvent.EventType.ACTIVATED) { + return; + } + String description = event.getDescription(); + if (description != null && description.startsWith("content:")) { + navigationHandler.navigateTo(description.substring("content:".length())); + return; + } + if (event.getURL() != null) { + URI uri = URI.create(event.getURL().toExternalForm()); + if ("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) { + navigationHandler.openExternal(uri); + } + } + }); + pane.setText(html); + pane.setCaretPosition(0); + JPanel panel = new JPanel(new BorderLayout()); + panel.add(new JScrollPane(pane), BorderLayout.CENTER); + return panel; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/UnsupportedContentRenderer.java b/app/src/main/java/com/basic4gl/desktop/content/render/UnsupportedContentRenderer.java new file mode 100644 index 00000000..f476044a --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/UnsupportedContentRenderer.java @@ -0,0 +1,22 @@ +package com.basic4gl.desktop.content.render; + +import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; + +import javax.swing.JComponent; + +public final class UnsupportedContentRenderer implements ContentRenderer { + + @Override + public boolean supports(String mediaType) { + return true; + } + + @Override + public JComponent render(ContentRenderRequest request) { + String html = "" + + "

Unsupported content

This document uses media type " + + escapeHtml(request.document().mediaType()) + + ", which cannot be displayed by this version of Basic4GLj.

"; + return RendererSupport.htmlComponent(html, request.navigationHandler()); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/debugger/IDebugPresenter.java b/app/src/main/java/com/basic4gl/desktop/debugger/IDebugPresenter.java new file mode 100644 index 00000000..088d8388 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/debugger/IDebugPresenter.java @@ -0,0 +1,17 @@ +package com.basic4gl.desktop.debugger; + +import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; +import com.basic4gl.desktop.editor.ApMode; + +public interface IDebugPresenter { + + void updateCallStack(StackTraceCallback message); + + void refreshWatchList(); + + void updateEvaluateWatch(String evaluatedWatch, String result); + + void clearCallStack(); + + void refreshDebugControls(ApMode mode); +} diff --git a/app/src/main/java/com/basic4gl/desktop/ApMode.java b/app/src/main/java/com/basic4gl/desktop/editor/ApMode.java similarity index 57% rename from app/src/main/java/com/basic4gl/desktop/ApMode.java rename to app/src/main/java/com/basic4gl/desktop/editor/ApMode.java index 5374a91e..bb01a4d0 100644 --- a/app/src/main/java/com/basic4gl/desktop/ApMode.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/ApMode.java @@ -1,6 +1,6 @@ -package com.basic4gl.desktop; +package com.basic4gl.desktop.editor; -enum ApMode { +public enum ApMode { AP_CLOSED, AP_STOPPED, AP_WAITING, diff --git a/app/src/main/java/com/basic4gl/desktop/IEditorPresenter.java b/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java similarity index 82% rename from app/src/main/java/com/basic4gl/desktop/IEditorPresenter.java rename to app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java index bea35424..cf4b7b44 100644 --- a/app/src/main/java/com/basic4gl/desktop/IEditorPresenter.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java @@ -1,14 +1,17 @@ -package com.basic4gl.desktop; +package com.basic4gl.desktop.editor; import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.callbacks.VariablesCallback; +import com.basic4gl.desktop.content.ContentDocumentViewer; import java.io.File; import java.util.List; -interface IEditorPresenter { +public interface IEditorPresenter { void onModeChanged(ApMode mode, String statusMsg); + void onCompileSucceeded(); + void refreshDebugDisplays(ApMode mode); void placeCursorAtProcessed(final int line, int col); @@ -40,4 +43,6 @@ interface IEditorPresenter { void setRecentItems(List files); void refreshSyntaxHighlighting(); + + void openDocumentationPreview(ContentDocumentViewer viewer); } diff --git a/app/src/main/java/com/basic4gl/desktop/editor/IFileEditorActionListener.java b/app/src/main/java/com/basic4gl/desktop/editor/IFileEditorActionListener.java index 8e202597..4b868b1c 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/IFileEditorActionListener.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/IFileEditorActionListener.java @@ -2,4 +2,6 @@ public interface IFileEditorActionListener { void onSearchResult(String message); + + void onBookmarksChanged(String filePath); } diff --git a/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java b/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java new file mode 100644 index 00000000..076bbfff --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java @@ -0,0 +1,85 @@ +package com.basic4gl.desktop.editor; + +import java.io.File; +import javax.swing.*; + +/** + * Base interface for file viewers. All viewers (text editors, image viewers, audio players, hex viewers, etc.) + * implement this interface. + * + * This allows the IDE to support different types of files with different viewing/editing mechanisms while maintaining + * a consistent interface for tab management. + */ +public interface IFileViewer { + /** + * @return the title to display in the tab + */ + String getTitle(); + + /** + * @return the full file path or empty string if unsaved + */ + String getFilePath(); + + /** + * @return the JComponent to display in the tab + */ + JComponent getContentPane(); + + /** + * @return the File object associated with this viewer, or null if unsaved + */ + File getFile(); + + /** + * @return the short filename (just the name, not the full path) + */ + String getShortFilename(); + + /** + * @return true if the file has been modified since opening/saving + */ + boolean isModified(); + + /** + * Mark this viewer's content as modified + */ + void setModified(); + + /** + * @return the viewer type (for identifying which viewer is being used) + */ + ViewerType getViewerType(); + + boolean hasPreview(); + + void setViewMode(ViewMode viewMode); + + ViewMode getViewMode(); + + enum ViewMode { + DEFAULT, + EDITOR, + EDITOR_AND_PREVIEW, + PREVIEW, + } + + /** + * Enumeration of supported viewer types + */ + enum ViewerType { + TEXT_EDITOR("Text Editor"), + IMAGE_VIEWER("Image Viewer"), + AUDIO_VIEWER("Audio Viewer"), + HEX_VIEWER("Hex Editor"), + MARKDOWN_VIEWER("Markdown Viewer"), + HTML_VIEWER("HTML Viewer"), + DOCUMENTATION_VIEWER("Documentation"); + + public final String display; + + ViewerType(String display) { + this.display = display; + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java new file mode 100644 index 00000000..f2bfbca8 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java @@ -0,0 +1,762 @@ +package com.basic4gl.desktop.panels; + +import static com.basic4gl.desktop.Theme.ICON_MENU_ASSETS; +import static com.basic4gl.desktop.Theme.ICON_MENU_FOLDER; +import static com.basic4gl.desktop.Theme.ICON_REFRESH; +import static com.basic4gl.desktop.Theme.ICON_SEARCH; +import static com.basic4gl.desktop.Theme.ICON_VIEW_GRID; +import static com.basic4gl.desktop.Theme.ICON_VIEW_LIST; +import static com.basic4gl.desktop.util.FileUtil.*; +import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; +import static com.basic4gl.desktop.util.SwingIconUtil.*; +import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; +import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; + +import com.basic4gl.desktop.content.FileEditor; +import com.basic4gl.desktop.content.FileManager; +import com.basic4gl.desktop.content.FileViewerFactory; +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.FileUtil; +import com.basic4gl.desktop.spi.LanguageService; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.util.RoundedCardPanel; +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; +import java.util.*; +import java.util.List; +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.filechooser.FileSystemView; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; + +public class AssetsPanelProvider implements IEditorPanelProvider { + + private final FileSystemView fileSystemView = FileSystemView.getFileSystemView(); + private final JTree assetsTree = new JTree(); + private final DefaultListModel assetsListModel = new DefaultListModel<>(); + private final JList assetsGridList = new JList<>(assetsListModel); + private final JTextField assetsSearchField = new JTextField(); + private final JPanel assetsContentPanel = new JPanel(new CardLayout()); + private final Map assetThumbnailCache = new HashMap<>(); + private static final String LAYOUT_TREE = "Tree"; + private static final String LAYOUT_GRID = "Grid"; + private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); + private static final Dimension HEADER_LAYOUT_BUTTON_SIZE = new Dimension(34, 30); + private static final Color SEGMENTED_BACKGROUND = new Color(0xE0E0E0); + private static final String SEGMENTED_BUTTON_STYLE = + "arc: 14; borderWidth: 0; focusWidth: 0; innerFocusWidth: 0;" + + " margin: 6,8,6,8; background: #E0E0E0;" + + " hoverBackground: #D6D6D6; selectedBackground: #FFFFFF"; + + private FileManager fileManager; + + private PluginContext context; + + private static final class AssetItem { + final String title; + final String subtitle; + final File file; + final Icon icon; + + AssetItem(String title, String subtitle, File file, Icon icon) { + this.title = title; + this.subtitle = subtitle; + this.file = file; + this.icon = icon; + } + + boolean isOpenable() { + return file != null && file.isFile(); + } + + @Override + public String toString() { + return title; + } + } + + public AssetsPanelProvider(FileManager fileManager) { + this.fileManager = fileManager; + } + + @Override + public String id() { + return "assets"; + } + + @Override + public String getTitle() { + return "Assets"; + } + + @Override + public String getActiveIconPath() { + return ICON_MENU_ASSETS; + } + + @Override + public String getInactiveIconPath() { + return ICON_MENU_ASSETS; + } + + @Override + public Color getActiveIconTint() { + return null; + } + + @Override + public EditorLayout getLayoutConstraints() { + return EditorLayout.WEST; + } + + @Override + public JPanel build(PluginContext context) { + this.context = context; + + JPanel panelCardHost = new JPanel(new CardLayout()); + JPanel panel = new JPanel(new BorderLayout(0, 6)); + Color panelBackground = createLighterPanelBackground(); + panel.setBackground(panelBackground); + JPanel header = new JPanel(new BorderLayout()); + header.setBackground(panelBackground); + JPanel headerButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); + headerButtons.setOpaque(false); + JLabel title = new JLabel("Assets"); + Font baseFont = title.getFont(); + title.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + title.setForeground(new Color(0x424242)); + title.setBorder(new EmptyBorder(0, 8, 0, 8)); + JPanel layoutTabs = createSegmentedButtonStrip(); + ButtonGroup layoutButtons = new ButtonGroup(); + JToggleButton treeLayoutButton = createAssetsLayoutButton("List View", ICON_VIEW_LIST, LAYOUT_TREE, "first"); + JToggleButton gridLayoutButton = createAssetsLayoutButton("Grid View", ICON_VIEW_GRID, LAYOUT_GRID, "last"); + layoutButtons.add(treeLayoutButton); + layoutButtons.add(gridLayoutButton); + treeLayoutButton.setSelected(true); + layoutTabs.add(treeLayoutButton); + layoutTabs.add(gridLayoutButton); + JToggleButton searchToggle = createHeaderSearchToggleButton(); + JButton refresh = createHeaderIconButton(ICON_REFRESH, "Refresh Assets"); + refresh.addActionListener(e -> refresh(context.currentEditor())); + headerButtons.add(layoutTabs); + headerButtons.add(searchToggle); + headerButtons.add(refresh); + header.add(title, BorderLayout.WEST); + header.add(headerButtons, BorderLayout.EAST); + panel.add(header, BorderLayout.NORTH); + + assetsTree.setBackground(panelBackground); + assetsTree.setBorder(null); + assetsTree.setRootVisible(false); + assetsTree.setShowsRootHandles(true); + // Let Swing compute preferred row height so custom/HTML labels do not clip. + assetsTree.setRowHeight(0); + assetsTree.setCellRenderer(new DefaultTreeCellRenderer() { + @Override + public Component getTreeCellRendererComponent( + JTree tree, + Object value, + boolean selected, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus) { + JLabel label = (JLabel) + super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); + if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof AssetItem item) { + boolean isSection = item.file == null; + label.setIcon(item.icon); + label.setIconTextGap(8); + label.setBorder(new EmptyBorder(3, 0, 3, 0)); + label.setText(formatAssetTreeLabel(item, isSection)); + label.setIcon(item.icon); + label.setToolTipText(item.file != null ? item.file.getAbsolutePath() : item.subtitle); + } + return label; + } + }); + + assetsTree.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + maybeShowAssetsTreePopup(e); + if (e.getClickCount() != 2) { + return; + } + TreePath path = assetsTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof AssetItem item) || !item.isOpenable()) { + return; + } + openAssetItem(item); + } + + @Override + public void mousePressed(MouseEvent e) { + maybeShowAssetsTreePopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybeShowAssetsTreePopup(e); + } + }); + + JScrollPane scrollPane = new JScrollPane(assetsTree); + scrollPane.setBorder(null); + configureSmoothScrolling(scrollPane); + + assetsGridList.setBorder(null); + assetsGridList.setBackground(panelBackground); + assetsGridList.setLayoutOrientation(JList.HORIZONTAL_WRAP); + assetsGridList.setVisibleRowCount(-1); + assetsGridList.setFixedCellHeight(112); + assetsGridList.setFixedCellWidth(120); + assetsGridList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + assetsGridList.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent( + JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + JLabel label = + (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof AssetItem item) { + label.setText("
" + escapeHtml(item.title) + "
"); + label.setIcon(getAssetGridIcon(item)); + label.setHorizontalTextPosition(SwingConstants.CENTER); + label.setVerticalTextPosition(SwingConstants.BOTTOM); + label.setHorizontalAlignment(SwingConstants.CENTER); + label.setToolTipText(item.file != null ? item.file.getAbsolutePath() : item.subtitle); + } + return label; + } + }); + assetsGridList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + maybeShowAssetsGridPopup(e); + if (e.getClickCount() != 2) { + return; + } + AssetItem item = assetsGridList.getSelectedValue(); + if (item == null || !item.isOpenable()) { + return; + } + openAssetItem(item); + } + + @Override + public void mousePressed(MouseEvent e) { + maybeShowAssetsGridPopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybeShowAssetsGridPopup(e); + } + }); + + JScrollPane gridScrollPane = new JScrollPane(assetsGridList); + gridScrollPane.setBorder(null); + configureSmoothScrolling(gridScrollPane); + + JPanel searchBar = new JPanel(new BorderLayout(6, 0)); + searchBar.setBackground(panelBackground); + searchBar.setBorder(new EmptyBorder(0, 8, 0, 8)); + assetsSearchField.setToolTipText("Search assets"); + searchBar.add(assetsSearchField, BorderLayout.CENTER); + searchBar.setVisible(false); + assetsSearchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + refresh(context.currentEditor()); + } + + @Override + public void removeUpdate(DocumentEvent e) { + refresh(context.currentEditor()); + } + + @Override + public void changedUpdate(DocumentEvent e) { + refresh(context.currentEditor()); + } + }); + searchToggle.addActionListener(e -> { + boolean visible = searchToggle.isSelected(); + searchBar.setVisible(visible); + if (visible) { + assetsSearchField.requestFocusInWindow(); + } + panel.revalidate(); + panel.repaint(); + }); + + assetsContentPanel.add(scrollPane, LAYOUT_TREE); + assetsContentPanel.add(gridScrollPane, LAYOUT_GRID); + showAssetsLayout(LAYOUT_TREE); + JPanel content = new JPanel(new BorderLayout(0, 6)); + content.setBackground(panelBackground); + content.add(searchBar, BorderLayout.NORTH); + content.add(assetsContentPanel, BorderLayout.CENTER); + panel.add(content, BorderLayout.CENTER); + panelCardHost.add(createRoundedCardHost(panel, panelBackground, "assets-main"), "main"); + ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); + return panelCardHost; + } + + private JToggleButton createAssetsLayoutButton( + String tooltip, String iconPath, String layoutKey, String segmentPosition) { + JToggleButton button = new JToggleButton(createScaledIcon(iconPath, 18)); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "segmented"); + button.putClientProperty("JButton.segmentPosition", segmentPosition); + button.putClientProperty("FlatLaf.style", SEGMENTED_BUTTON_STYLE); + button.setOpaque(false); + button.setMargin(new Insets(6, 8, 6, 8)); + button.setPreferredSize(HEADER_LAYOUT_BUTTON_SIZE); + button.setMinimumSize(HEADER_LAYOUT_BUTTON_SIZE); + button.setMaximumSize(HEADER_LAYOUT_BUTTON_SIZE); + button.addActionListener(e -> showAssetsLayout(layoutKey)); + return button; + } + + private JPanel createSegmentedButtonStrip() { + JPanel panel = new RoundedCardPanel(RoundedCardPanel.DEFAULT_ARC); + panel.setLayout(new BoxLayout(panel, BoxLayout.LINE_AXIS)); + panel.setBackground(SEGMENTED_BACKGROUND); + panel.setBorder(new EmptyBorder(1, 1, 1, 1)); + return panel; + } + + private JButton createHeaderIconButton(String iconPath, String tooltip) { + JButton button = new JButton(createScaledIcon(iconPath, 18)); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private JToggleButton createHeaderSearchToggleButton() { + JToggleButton button = new JToggleButton(createScaledIcon(ICON_SEARCH, 18)); + button.setToolTipText("Show search"); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { + Color cardBackground = createLighterPanelBackground(); + JPanel card = new RoundedCardPanel(); + card.setLayout(new BorderLayout()); + card.setBackground(cardBackground); + card.setBorder(new EmptyBorder(4, 4, 4, 4)); + card.add(content, BorderLayout.CENTER); + + JPanel host = new JPanel(new CardLayout()); + host.setOpaque(false); + host.add(card, key); + ((CardLayout) host.getLayout()).show(host, key); + return host; + } + + private void showAssetsLayout(String layoutKey) { + CardLayout layout = (CardLayout) assetsContentPanel.getLayout(); + layout.show(assetsContentPanel, layoutKey); + } + + private String formatAssetTreeLabel(AssetItem item, boolean isSection) { + if (item == null) { + return ""; + } + String title = escapeHtml(item.title == null ? "" : item.title); + String subtitle = item.subtitle == null ? "" : item.subtitle.trim(); + if (subtitle.isBlank()) { + return isSection ? "" + title + "" : title; + } + String subtitleHtml = escapeHtml(subtitle); + if (isSection) { + return "" + title + " " + subtitleHtml + ""; + } + return "" + title + " " + subtitleHtml + ""; + } + + private void openAssetItem(AssetItem item) { + if (item == null || !item.isOpenable()) { + return; + } + + context.commands().openFileWithPreferredViewer(item.file); + } + + private void maybeShowAssetsTreePopup(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + TreePath path = assetsTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + assetsTree.setSelectionPath(path); + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof AssetItem item)) { + return; + } + showAssetsPopup(item, assetsTree, e.getX(), e.getY()); + } + + private void maybeShowAssetsGridPopup(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + int index = assetsGridList.locationToIndex(e.getPoint()); + if (index < 0) { + return; + } + assetsGridList.setSelectedIndex(index); + AssetItem item = assetsGridList.getModel().getElementAt(index); + showAssetsPopup(item, assetsGridList, e.getX(), e.getY()); + } + + private void showAssetsPopup(AssetItem item, Component invoker, int x, int y) { + if (item == null) { + return; + } + + JPopupMenu popup = new JPopupMenu(); + + JMenuItem openItem = new JMenuItem("Open"); + openItem.setEnabled(item.isOpenable()); + openItem.addActionListener(evt -> openAssetItem(item)); + popup.add(openItem); + + JMenuItem revealItem = new JMenuItem("Reveal in Finder"); + revealItem.setEnabled(item.file != null); + revealItem.addActionListener(evt -> revealInFinder(item.file, context.dialogs())); + popup.add(revealItem); + + JMenuItem systemItem = new JMenuItem("Open with Default App"); + systemItem.setEnabled(item.file != null); + systemItem.addActionListener(evt -> openWithSystemDefault(item.file, context.dialogs())); + popup.add(systemItem); + + JMenuItem copyPathItem = new JMenuItem("Copy Path"); + copyPathItem.setEnabled(item.file != null); + copyPathItem.addActionListener(evt -> { + StringSelection selection = new StringSelection(item.file.getAbsolutePath()); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + }); + popup.add(copyPathItem); + + popup.addSeparator(); + JMenuItem refreshItem = new JMenuItem("Refresh Assets"); + refreshItem.addActionListener(evt -> refresh(context.currentEditor())); + popup.add(refreshItem); + + popup.show(invoker, x, y); + } + + @Override + public void refresh(EditorPlugin languageProvider) { + if (context == null) { + return; + } + File rootDir = new File(context.currentDirectory()); + String searchNeedle = assetsSearchField.getText() == null + ? "" + : assetsSearchField.getText().trim().toLowerCase(Locale.ROOT); + assetThumbnailCache.clear(); + DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(new AssetItem( + "Assets", + "Workspace resources, libraries, and embedded literals", + null, + createImageIcon(ICON_MENU_ASSETS))); + + java.util.List workspaceAssets = collectWorkspaceAssets(rootDir, 0, 4); + workspaceAssets = filterAssetFiles(workspaceAssets, rootDir, searchNeedle); + DefaultMutableTreeNode workspaceNode = buildMediaTypeSection( + "Workspace Resources", workspaceAssets, rootDir, createImageIcon(ICON_MENU_FOLDER)); + if (workspaceNode != null) { + rootNode.add(workspaceNode); + } + + java.util.List literalAssets = + detectLiteralAssets(rootDir, context.currentEditor().getLanguage()); + literalAssets = filterAssetFiles(literalAssets, rootDir, searchNeedle); + DefaultMutableTreeNode literalNode = + buildMediaTypeSection("Embedded Literals", literalAssets, rootDir, createImageIcon(ICON_MENU_ASSETS)); + if (literalNode != null) { + rootNode.add(literalNode); + } + + assetsTree.setModel(new DefaultTreeModel(rootNode)); + for (int i = 0; i < Math.min(4, assetsTree.getRowCount()); i++) { + assetsTree.expandRow(i); + } + + assetsListModel.clear(); + for (AssetItem item : collectOpenableAssets(rootNode)) { + assetsListModel.addElement(item); + } + } + + private java.util.List filterAssetFiles(java.util.List files, File baseDir, String searchNeedle) { + if (files == null || files.isEmpty()) { + return Collections.emptyList(); + } + if (searchNeedle == null || searchNeedle.isBlank()) { + return files; + } + java.util.List filtered = new ArrayList<>(); + for (File file : files) { + if (file == null) { + continue; + } + String name = file.getName().toLowerCase(Locale.ROOT); + String absolute = file.getAbsolutePath().toLowerCase(Locale.ROOT); + String relative = formatRelativePath(file, baseDir); + String relativeLower = relative == null ? "" : relative.toLowerCase(Locale.ROOT); + if (name.contains(searchNeedle) + || absolute.contains(searchNeedle) + || relativeLower.contains(searchNeedle)) { + filtered.add(file); + } + } + return filtered; + } + + @Override + public void onFileModified(String filePath) {} + + @Override + public void dispose() {} + + @Override + public void onCompileSucceeded() {} + + private java.util.List collectOpenableAssets(DefaultMutableTreeNode rootNode) { + java.util.List items = new ArrayList<>(); + if (rootNode == null) { + return items; + } + java.util.Enumeration enumeration = rootNode.depthFirstEnumeration(); + while (enumeration.hasMoreElements()) { + Object next = enumeration.nextElement(); + if (!(next instanceof DefaultMutableTreeNode node)) { + continue; + } + if (node.getUserObject() instanceof AssetItem item && item.isOpenable()) { + items.add(item); + } + } + return items; + } + + private DefaultMutableTreeNode buildMediaTypeSection( + String title, java.util.List files, File baseDir, Icon sectionIcon) { + if (files == null || files.isEmpty()) { + return null; + } + Map> byMediaType = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (File file : files) { + String mediaType = getMediaTypeLabel(file); + byMediaType.computeIfAbsent(mediaType, k -> new ArrayList<>()).add(file); + } + DefaultMutableTreeNode section = + new DefaultMutableTreeNode(new AssetItem(title, files.size() + " file(s)", null, sectionIcon)); + for (String mediaType : List.of("Images", "Audio", "Video", "Text", "Documents", "Other")) { + java.util.List bucket = byMediaType.get(mediaType); + if (bucket == null || bucket.isEmpty()) { + continue; + } + DefaultMutableTreeNode typeNode = new DefaultMutableTreeNode( + new AssetItem(mediaType, bucket.size() + " file(s)", null, createImageIcon(ICON_MENU_FOLDER))); + bucket.sort(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + for (File file : bucket) { + typeNode.add(new DefaultMutableTreeNode(createAssetItem(file, baseDir, mediaType))); + } + section.add(typeNode); + } + return section; + } + + private void collectResolvedLibraryAssets(java.util.List paths, File baseDir, java.util.List out) { + if (paths == null || paths.isEmpty()) { + return; + } + for (String path : paths) { + if (path == null || path.isBlank()) { + continue; + } + File resolved = resolveAssetReference(path, baseDir, null); + if (resolved == null || !resolved.exists()) { + continue; + } + if (resolved.isDirectory()) { + collectWorkspaceAssets(resolved, 0, 2, out); + } else { + out.add(resolved); + } + } + } + + private java.util.List collectWorkspaceAssets(File directory, int depth, int maxDepth) { + java.util.List assets = new ArrayList<>(); + collectWorkspaceAssets(directory, depth, maxDepth, assets); + return assets; + } + + private void collectWorkspaceAssets(File directory, int depth, int maxDepth, java.util.List out) { + if (directory == null || !directory.isDirectory() || depth > maxDepth) { + return; + } + if (shouldSkipAssetDirectory(directory)) { + return; + } + + File[] files = directory.listFiles(); + if (files == null) { + return; + } + Arrays.sort(files, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + for (File file : files) { + if (file == null || file.getName().startsWith(".")) { + continue; + } + if (file.isDirectory()) { + collectWorkspaceAssets(file, depth + 1, maxDepth, out); + } else if (isKnownAssetFile(file)) { + out.add(file); + } + } + } + + private boolean shouldSkipAssetDirectory(File directory) { + String name = directory.getName().toLowerCase(Locale.ROOT); + return name.equals("build") + || name.equals("out") + || name.equals("target") + || name.equals("bin") + || name.equals("dist") + || name.equals("node_modules") + || name.equals(".gradle") + || name.equals(".git"); + } + + private AssetItem createAssetItem(File file, File baseDir, String subtitlePrefix) { + String subtitle = subtitlePrefix; + if (file != null) { + String relative = formatRelativePath(file, baseDir); + if (relative != null && !relative.isBlank()) { + subtitle = subtitle == null || subtitle.isBlank() ? relative : subtitle + " • " + relative; + } + } + return new AssetItem( + file != null ? file.getName() : "(unknown)", + subtitle, + file, + file != null ? fileSystemView.getSystemIcon(file) : createImageIcon(ICON_MENU_FOLDER)); + } + + private Icon getAssetGridIcon(AssetItem item) { + if (item == null || item.file == null) { + return createImageIcon(ICON_MENU_ASSETS); + } + String cacheKey = item.file.getAbsolutePath(); + Icon cached = assetThumbnailCache.get(cacheKey); + if (cached != null) { + return cached; + } + + Icon icon = item.icon; + String lower = item.file.getName().toLowerCase(Locale.ROOT); + if (FileViewerFactory.isImageFile(lower)) { + icon = buildImageThumbnailIcon(item.file, 84, 64); + } + if (icon == null) { + icon = createImageIcon(ICON_MENU_ASSETS); + } + assetThumbnailCache.put(cacheKey, icon); + return icon; + } + + private java.util.List detectLiteralAssets(File baseDir, LanguageService languageService) { + java.util.LinkedHashSet detected = new java.util.LinkedHashSet<>(); + if (fileManager == null) { + return new ArrayList<>(); + } + + for (FileEditor editor : fileManager.getFileEditors()) { + if (editor == null || editor.getEditorPane() == null) { + continue; + } + String text = editor.getEditorPane().getText(); + if (text == null || text.isBlank()) { + continue; + } + File sourceFile = editor.getFile(); + File sourceParent = + sourceFile != null ? sourceFile.getAbsoluteFile().getParentFile() : null; + for (String literal : languageService.extractStringLiterals(text)) { + if (literal == null || literal.isBlank()) { + continue; + } + File resolved = resolveAssetReference(literal, baseDir, sourceParent); + if (resolved != null && resolved.exists() && resolved.isFile()) { + detected.add(resolved); + } + } + } + return new ArrayList<>(detected); + } + + private File resolveAssetReference(String literal, File baseDir, File sourceParent) { + if (literal == null || literal.isBlank()) { + return null; + } + String normalized = FileUtil.separatorsToSystem(literal); + File candidate = new File(normalized); + if (candidate.isAbsolute()) { + return candidate; + } + if (baseDir != null) { + File workspace = new File(baseDir, normalized); + if (workspace.exists()) { + return workspace; + } + } + if (sourceParent != null) { + File sibling = new File(sourceParent, normalized); + if (sibling.exists()) { + return sibling; + } + } + return candidate; + } + + private boolean isKnownAssetFile(File file) { + String name = file.getName().toLowerCase(Locale.ROOT); + return getMediaTypeLabel(file) != null && !"Other".equals(getMediaTypeLabel(file)); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java new file mode 100644 index 00000000..1fa59b02 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java @@ -0,0 +1,363 @@ +package com.basic4gl.desktop.panels; + +import static com.basic4gl.desktop.Theme.*; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; + +import com.basic4gl.desktop.spi.BookmarkInfo; +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.util.RoundedCardPanel; +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +public class BookmarksPanelProvider implements IEditorPanelProvider { + private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); + private static final String BOOKMARKS_LIST_CARD = "list"; + private static final String BOOKMARKS_EMPTY_CARD = "empty"; + + private final List allBookmarks = new ArrayList<>(); + private final DefaultListModel bookmarkListModel = new DefaultListModel<>(); + private final JList bookmarkList = new JList<>(bookmarkListModel); + private final JTextField bookmarkSearchField = new JTextField(); + private final JPanel bookmarkResultsHost = new JPanel(new CardLayout()); + private final JLabel emptyStateLabel = new JLabel("No Bookmarks"); + private PluginContext context; + + @Override + public String id() { + return "bookmarks"; + } + + @Override + public String getTitle() { + return "Bookmarks"; + } + + @Override + public String getActiveIconPath() { + return ICON_MENU_BOOKMARKS; + } + + @Override + public String getInactiveIconPath() { + return ICON_MENU_BOOKMARKS_OUTLINE; + } + + @Override + public Color getActiveIconTint() { + return null; + } + + @Override + public EditorLayout getLayoutConstraints() { + return EditorLayout.WEST; + } + + @Override + public JPanel build(PluginContext context) { + this.context = context; + + JPanel panelCardHost = new JPanel(new CardLayout()); + JPanel panel = new JPanel(new BorderLayout(0, 6)); + Color panelBackground = createLighterPanelBackground(); + panel.setBackground(panelBackground); + panel.setOpaque(true); + + JPanel header = new JPanel(); + header.setLayout(new BoxLayout(header, BoxLayout.X_AXIS)); + header.setBackground(panelBackground); + header.setOpaque(true); + + JLabel title = new JLabel("Bookmarks"); + Font baseFont = title.getFont(); + title.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + title.setForeground(new Color(0x424242)); + title.setBorder(new EmptyBorder(0, 8, 0, 8)); + + JButton toggleBookmarkButton = createHeaderIconButton(ICON_BOOKMARK_ADD, "Toggle bookmark"); + toggleBookmarkButton.addActionListener(e -> toggleBookmarkAndReload()); + + JButton nextBookmarkButton = createHeaderIconButton(ICON_ARROW_DOWN, "Next bookmark"); + nextBookmarkButton.addActionListener(e -> { + if (this.context != null && this.context.commands() != null) { + this.context.commands().selectNextBookmark(); + reloadBookmarks(); + } + }); + + JButton previousBookmarkButton = createHeaderIconButton(ICON_ARROW_UP, "Previous bookmark"); + previousBookmarkButton.addActionListener(e -> { + if (this.context != null && this.context.commands() != null) { + this.context.commands().selectPreviousBookmark(); + reloadBookmarks(); + } + }); + + JToggleButton searchToggle = createHeaderSearchToggleButton(); + header.add(title); + header.add(Box.createHorizontalGlue()); + header.add(toggleBookmarkButton); + header.add(Box.createHorizontalStrut(4)); + header.add(nextBookmarkButton); + header.add(Box.createHorizontalStrut(4)); + header.add(previousBookmarkButton); + header.add(Box.createHorizontalStrut(4)); + header.add(searchToggle); + panel.add(header, BorderLayout.NORTH); + + JPanel searchBar = new JPanel(new BorderLayout(6, 0)); + searchBar.setBackground(panelBackground); + searchBar.setBorder(new EmptyBorder(0, 8, 0, 8)); + bookmarkSearchField.setToolTipText("Search bookmarks"); + searchBar.add(bookmarkSearchField, BorderLayout.CENTER); + searchBar.setVisible(false); + searchToggle.addActionListener(e -> { + boolean visible = searchToggle.isSelected(); + searchBar.setVisible(visible); + if (visible) { + bookmarkSearchField.requestFocusInWindow(); + } + panel.revalidate(); + panel.repaint(); + }); + bookmarkSearchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + filterBookmarks(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + filterBookmarks(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + filterBookmarks(); + } + }); + + bookmarkList.setBackground(panelBackground); + bookmarkList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + bookmarkList.setFixedCellHeight(22); + bookmarkList.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent( + JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + JLabel label = + (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof BookmarkInfo bookmark) { + String preview = bookmark.lineText() == null ? "" : bookmark.lineText(); + String text = bookmark.fileName() + ":" + (bookmark.lineNumber() + 1); + if (!preview.isBlank()) { + text += " " + preview; + } + label.setText(text); + label.setToolTipText(bookmark.filePath()); + } + return label; + } + }); + bookmarkList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + goToSelectedBookmark(); + } + } + }); + bookmarkList.getInputMap(JComponent.WHEN_FOCUSED).put(KeyStroke.getKeyStroke("ENTER"), "openBookmark"); + bookmarkList.getActionMap().put("openBookmark", new AbstractAction() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + goToSelectedBookmark(); + } + }); + + JPanel content = new JPanel(new BorderLayout(0, 6)); + content.setBackground(panelBackground); + content.add(searchBar, BorderLayout.NORTH); + + JScrollPane scrollPane = new JScrollPane(bookmarkList); + scrollPane.setBackground(panelBackground); + scrollPane.setBorder(null); + + JPanel emptyStatePanel = new JPanel(new GridBagLayout()); + emptyStatePanel.setBackground(panelBackground); + JPanel emptyStateContent = new JPanel(); + emptyStateContent.setOpaque(false); + emptyStateContent.setLayout(new BoxLayout(emptyStateContent, BoxLayout.Y_AXIS)); + Color disabledIconColor = UIManager.getColor("Label.disabledForeground"); + if (disabledIconColor == null) { + disabledIconColor = new Color(0xC8C8C8); + } + JLabel emptyIconLabel = new JLabel(createScaledIcon(ICON_BOOKMARK_ADD, 40, disabledIconColor)); + emptyIconLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + emptyStateLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + Font emptyStateFont = emptyStateLabel.getFont(); + emptyStateLabel.setFont(new Font(emptyStateFont.getName(), Font.BOLD, emptyStateFont.getSize() + 2)); + emptyStateLabel.setForeground(new Color(0x5B717F)); + JButton addBookmarkButton = new JButton("Add Bookmark"); + addBookmarkButton.setFocusable(false); + addBookmarkButton.setMargin(new Insets(4, 4, 4, 4)); + addBookmarkButton.setForeground(new Color(0x5B717F)); + addBookmarkButton.setAlignmentX(Component.CENTER_ALIGNMENT); + addBookmarkButton.addActionListener(e -> toggleBookmarkAndReload()); + emptyStateContent.add(emptyIconLabel); + emptyStateContent.add(Box.createVerticalStrut(8)); + emptyStateContent.add(emptyStateLabel); + emptyStateContent.add(Box.createVerticalStrut(8)); + emptyStateContent.add(addBookmarkButton); + emptyStatePanel.add(emptyStateContent); + + bookmarkResultsHost.setOpaque(false); + bookmarkResultsHost.add(scrollPane, BOOKMARKS_LIST_CARD); + bookmarkResultsHost.add(emptyStatePanel, BOOKMARKS_EMPTY_CARD); + content.add(bookmarkResultsHost, BorderLayout.CENTER); + + panel.add(content, BorderLayout.CENTER); + panelCardHost.add(createRoundedCardHost(panel, panelBackground, "bookmarks-main"), "main"); + ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); + + reloadBookmarks(); + return panelCardHost; + } + + @Override + public void refresh(EditorPlugin languageProvider) { + reloadBookmarks(); + } + + @Override + public void onFileModified(String filePath) { + reloadBookmarks(); + } + + @Override + public void dispose() {} + + @Override + public void onCompileSucceeded() {} + + private void reloadBookmarks() { + if (context == null || context.commands() == null) { + return; + } + allBookmarks.clear(); + allBookmarks.addAll(context.commands().listBookmarks()); + filterBookmarks(); + } + + private void filterBookmarks() { + String needle = bookmarkSearchField.getText() == null + ? "" + : bookmarkSearchField.getText().trim().toLowerCase(Locale.ROOT); + BookmarkInfo previousSelection = bookmarkList.getSelectedValue(); + bookmarkListModel.clear(); + for (BookmarkInfo item : allBookmarks) { + String haystack = (item.fileName() + " " + item.lineText()).toLowerCase(Locale.ROOT); + if (needle.isEmpty() || haystack.contains(needle)) { + bookmarkListModel.addElement(item); + } + } + + if (!bookmarkListModel.isEmpty()) { + showBookmarkResults(true, needle); + if (previousSelection != null) { + bookmarkList.setSelectedValue(previousSelection, true); + } else { + bookmarkList.setSelectedIndex(0); + } + } else { + showBookmarkResults(false, needle); + } + } + + private void showBookmarkResults(boolean hasResults, String needle) { + if (hasResults) { + ((CardLayout) bookmarkResultsHost.getLayout()).show(bookmarkResultsHost, BOOKMARKS_LIST_CARD); + return; + } + + emptyStateLabel.setText(needle == null || needle.isBlank() ? "No Bookmarks" : "No Results"); + ((CardLayout) bookmarkResultsHost.getLayout()).show(bookmarkResultsHost, BOOKMARKS_EMPTY_CARD); + } + + private void goToSelectedBookmark() { + if (context == null || context.commands() == null) { + return; + } + BookmarkInfo item = bookmarkList.getSelectedValue(); + if (item == null) { + return; + } + context.commands().goToBookmark(item.filePath(), item.lineNumber()); + } + + private JButton createHeaderIconButton(String iconPath, String tooltip) { + JButton button = new JButton(createScaledIcon(iconPath, 18)); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private void toggleBookmarkAndReload() { + if (this.context != null && this.context.commands() != null) { + this.context.commands().toggleBookmark(); + reloadBookmarks(); + } + } + + private JToggleButton createHeaderSearchToggleButton() { + JToggleButton button = new JToggleButton(createScaledIcon(ICON_SEARCH, 18)); + button.setToolTipText("Show search"); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private Color createLighterPanelBackground() { + Color base = UIManager.getColor("Panel.background"); + if (base == null) { + base = new Color(238, 238, 238); + } + return new Color( + Math.min(255, base.getRed() + 8), + Math.min(255, base.getGreen() + 8), + Math.min(255, base.getBlue() + 8)); + } + + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { + Color cardBackground = createLighterPanelBackground(); + JPanel card = new RoundedCardPanel(); + card.setLayout(new BorderLayout()); + card.setBackground(cardBackground); + card.setBorder(new EmptyBorder(4, 4, 4, 4)); + card.add(content, BorderLayout.CENTER); + + JPanel host = new JPanel(new CardLayout()); + host.setOpaque(false); + host.add(card, key); + ((CardLayout) host.getLayout()).show(host, key); + return host; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java new file mode 100644 index 00000000..b405842a --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java @@ -0,0 +1,336 @@ +package com.basic4gl.desktop.panels; + +import static com.basic4gl.desktop.Theme.*; +import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; + +import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; +import com.basic4gl.desktop.BasicEditor; +import com.basic4gl.desktop.debugger.IDebugPresenter; +import com.basic4gl.desktop.editor.ApMode; +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.util.RoundedCardPanel; +import com.basic4gl.desktop.util.SwingUtil; +import java.awt.*; +import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.Objects; +import javax.swing.*; +import javax.swing.border.EmptyBorder; + +public class DebugPanelProvider implements IEditorPanelProvider, IDebugPresenter { + + private final JSplitPane debugPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + private final JButton playButton = new JButton(createImageIcon(ICON_PLAY)); + private final JButton stepOverButton = new JButton(createImageIcon(ICON_STEP_OVER)); + private final JButton stepInButton = new JButton(createImageIcon(ICON_STEP_IN)); + private final JButton stepOutButton = new JButton(createImageIcon(ICON_STEP_OUT)); + // Debugging + private final DefaultListModel watchListModel = new DefaultListModel<>(); + private final JList watchListBox = new JList<>(watchListModel); + private final DefaultListModel gosubListModel = new DefaultListModel<>(); + + private final BasicEditor basicEditor; + private PluginContext context; + + public DebugPanelProvider(BasicEditor basicEditor) { + // TODO this is working up to a circular dependency.. can't init BasicEditor with this as IDebugPresenter + this.basicEditor = basicEditor; + } + + @Override + public String id() { + return "debug"; + } + + @Override + public String getTitle() { + return "Debug"; + } + + @Override + public String getActiveIconPath() { + return ICON_DEBUG; + } + + @Override + public String getInactiveIconPath() { + return ICON_MENU_DEBUG; + } + + @Override + public Color getActiveIconTint() { + return null; + } + + @Override + public EditorLayout getLayoutConstraints() { + return EditorLayout.SOUTH; + } + + @Override + public JPanel build(PluginContext context) { + this.context = context; + + JPanel panelCardHost = new JPanel(new CardLayout()); + JPanel panel = new JPanel(new BorderLayout()); + Color panelBackground = createLighterPanelBackground(); + panel.setBackground(panelBackground); + + JLabel title = new JLabel("Debug"); + Font baseFont = title.getFont(); + title.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + title.setForeground(new Color(0x424242)); + title.setBorder(new EmptyBorder(0, 6, 0, 6)); + + JToolBar debugToolBar = new JToolBar(); + debugToolBar.setFloatable(false); + debugToolBar.setOpaque(false); + debugToolBar.add(title); + debugToolBar.add(playButton); + debugToolBar.add(stepOverButton); + debugToolBar.add(stepInButton); + debugToolBar.add(stepOutButton); + + playButton.setToolTipText("Play/Pause"); + stepOverButton.setToolTipText("Step Over"); + stepInButton.setToolTipText("Step In"); + stepOutButton.setToolTipText("Step Out"); + + playButton.addActionListener(e -> context.debugger().actionPlayPause()); + stepOverButton.addActionListener(e -> context.debugger().actionStep()); + stepInButton.addActionListener(e -> context.debugger().actionStepInto()); + stepOutButton.addActionListener(e -> context.debugger().actionStepOutOf()); + + panel.add(debugToolBar, BorderLayout.NORTH); + panel.add(buildDebugPanel(panelBackground), BorderLayout.CENTER); + panelCardHost.add(createRoundedCardHost(panel, panelBackground, "debug-main"), "main"); + ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); + return panelCardHost; + } + + private JPanel buildDebugPanel(Color panelBackground) { + // Debugger + JPanel watchListFrame = new JPanel(); + watchListFrame.setLayout(new BorderLayout()); + watchListFrame.setBackground(panelBackground); + JLabel watchlistLabel = new JLabel("Watchlist"); + + Font font = watchlistLabel.getFont(); + watchlistLabel.setFont(new Font(font.getName(), Font.BOLD, font.getSize())); + watchlistLabel.setForeground(new Color(0x000000)); + watchlistLabel.setBorder(new EmptyBorder(0, 8, 0, 8)); + + watchlistLabel.setBorder(new EmptyBorder(4, 8, 4, 8)); + watchListFrame.add(watchlistLabel, BorderLayout.NORTH); + JScrollPane watchListScrollPane = new JScrollPane(watchListBox); + watchListFrame.add(watchListScrollPane, BorderLayout.CENTER); + + watchListBox.addMouseListener(new MouseAdapter() { + public void mouseClicked(MouseEvent evt) { + JList list = (JList) evt.getSource(); + if (evt.getClickCount() == 2) { + // Double-click detected + int index = list.locationToIndex(evt.getPoint()); + editWatch(); + } + } + }); + + watchListBox.addKeyListener(new KeyListener() { + @Override + public void keyTyped(KeyEvent e) {} + + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ENTER) { + editWatch(); + } + } + + @Override + public void keyReleased(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_DELETE) { + deleteWatch(); + } else if (e.getKeyCode() == KeyEvent.VK_INSERT) { + watchListBox.setSelectedIndex(basicEditor.getWatchListSize()); + editWatch(); + } + } + }); + + watchListBox.addListSelectionListener(e -> updateWatchHint()); + + JPanel gosubFrame = new JPanel(); + gosubFrame.setLayout(new BorderLayout()); + gosubFrame.setBackground(panelBackground); + JLabel callstackLabel = new JLabel("Call Stack"); + + font = callstackLabel.getFont(); + callstackLabel.setFont(new Font(font.getName(), Font.BOLD, font.getSize())); + callstackLabel.setForeground(new Color(0x000000)); + callstackLabel.setBorder(new EmptyBorder(0, 8, 0, 8)); + + callstackLabel.setBorder(new EmptyBorder(4, 8, 4, 8)); + + gosubFrame.add(callstackLabel, BorderLayout.NORTH); + JList gosubListBox = new JList<>(gosubListModel); + JScrollPane gosubListScrollPane = new JScrollPane(gosubListBox); + gosubFrame.add(gosubListScrollPane, BorderLayout.CENTER); + + hideSplitPaneHandle(debugPane); + + debugPane.setBackground(panelBackground); + debugPane.setLeftComponent(watchListFrame); + debugPane.setRightComponent(gosubFrame); + + JPanel debugPanel = new JPanel(); + debugPanel.setLayout(new BorderLayout()); + debugPanel.setBackground(panelBackground); + debugPanel.add(debugPane, BorderLayout.CENTER); + + return debugPanel; + } + + private Color createLighterPanelBackground() { + Color base = UIManager.getColor("Panel.background"); + if (base == null) { + base = new Color(238, 238, 238); + } + return new Color( + Math.min(255, base.getRed() + 8), + Math.min(255, base.getGreen() + 8), + Math.min(255, base.getBlue() + 8)); + } + + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { + Color cardBackground = SwingUtil.createLighterPanelBackground(); + JPanel card = new RoundedCardPanel(); + card.setLayout(new BorderLayout()); + card.setBackground(cardBackground); + card.setBorder(new EmptyBorder(4, 4, 4, 4)); + card.add(content, BorderLayout.CENTER); + + JPanel host = new JPanel(new CardLayout()); + host.setOpaque(false); + host.add(card, key); + ((CardLayout) host.getLayout()).show(host, key); + return host; + } + + @Override + public void updateCallStack(StackTraceCallback stackTraceCallback) { + + // Clear debug controls + gosubListModel.clear(); + + for (String label : basicEditor.getLanguageService().buildFriendlyCallStackLabels(stackTraceCallback)) { + gosubListModel.addElement(label); + } + } + + @Override + public void refreshWatchList() { + // Clear debug controls + watchListModel.clear(); + + for (String watch : basicEditor.getWatches()) { + + watchListModel.addElement(watch + ": " + "???"); + } + watchListModel.addElement(" "); // Last line is blank, and can be clicked on to add new watch + } + + private void editWatch() { + if (context == null) { + return; + } + + String newWatch, oldWatch; + + // Find watch + int index = watchListBox.getSelectedIndex(); + int saveIndex = index; + + // Extract watch text + oldWatch = basicEditor.getWatchOrDefault(index); + + // Prompt for new text + newWatch = context.dialogs().showInputDialog("Enter variable/expression:", "Watch variable", oldWatch); + + basicEditor.updateWatch(newWatch, index); + + watchListBox.setSelectedIndex(saveIndex); + updateWatchHint(); + } + + void deleteWatch() { + + // Find watch + int index = watchListBox.getSelectedIndex(); + int saveIndex = index; + + // Delete watch + basicEditor.removeWatchAt(index); + + watchListBox.setSelectedIndex(saveIndex); + updateWatchHint(); + } + + private void updateWatchHint() { + int index = watchListBox.getSelectedIndex(); + if (index > -1 && index < basicEditor.getWatchListSize()) { + watchListBox.setToolTipText((String) watchListModel.get(index)); + } else { + watchListBox.setToolTipText(""); + } + } + + @Override + public void updateEvaluateWatch(String evaluatedWatch, String result) { + int index = 0; + for (String watch : basicEditor.getWatches()) { + if (Objects.equals(watch, evaluatedWatch)) { + watchListModel.setElementAt(watch + ": " + result, index); + } + index++; + } + } + + @Override + public void clearCallStack() { + gosubListModel.clear(); + } + + @Override + public void refreshDebugControls(ApMode mode) { + if (mode != ApMode.AP_CLOSED) { + playButton.setIcon(mode == ApMode.AP_RUNNING ? createImageIcon(ICON_PAUSE) : createImageIcon(ICON_PLAY)); + playButton.setEnabled(mode != ApMode.AP_WAITING); + stepOverButton.setEnabled(mode != ApMode.AP_RUNNING && mode != ApMode.AP_WAITING); + stepInButton.setEnabled(mode != ApMode.AP_RUNNING && mode != ApMode.AP_WAITING); + stepOutButton.setEnabled(mode == ApMode.AP_PAUSED); + return; + } + + playButton.setEnabled(false); + stepOverButton.setEnabled(false); + stepInButton.setEnabled(false); + stepOutButton.setEnabled(false); + } + + @Override + public void refresh(EditorPlugin languageProvider) {} + + @Override + public void onFileModified(String filePath) {} + + @Override + public void dispose() {} + + @Override + public void onCompileSucceeded() {} +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java new file mode 100644 index 00000000..ecb3a692 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java @@ -0,0 +1,704 @@ +package com.basic4gl.desktop.panels; + +import static com.basic4gl.desktop.Theme.ICON_CHEVRON_DOWN; +import static com.basic4gl.desktop.Theme.ICON_DOCUMENT; +import static com.basic4gl.desktop.Theme.ICON_MENU_DOCS; +import static com.basic4gl.desktop.Theme.ICON_MENU_DOCS_SOLID; +import static com.basic4gl.desktop.Theme.ICON_SEARCH; +import static com.basic4gl.desktop.Theme.ICON_TEMPLATE; +import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; +import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; +import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; + +import com.basic4gl.desktop.BasicEditor; +import com.basic4gl.desktop.content.catalog.ContentBrowseNode; +import com.basic4gl.desktop.content.catalog.ContentCatalogListener; +import com.basic4gl.desktop.content.catalog.ContentPanelItem; +import com.basic4gl.desktop.content.catalog.ContentPanelModel; +import com.basic4gl.desktop.content.catalog.ContentScope; +import com.basic4gl.desktop.content.catalog.ContentSelectionSummary; +import com.basic4gl.desktop.content.catalog.TemplateCatalogEntry; +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.util.RoundedCardPanel; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.Icon; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JMenuItem; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTextField; +import javax.swing.JTextPane; +import javax.swing.JToggleButton; +import javax.swing.JTree; +import javax.swing.ListSelectionModel; +import javax.swing.ScrollPaneConstants; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; + +public class DocsPanelProvider implements IEditorPanelProvider { + + private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); + private static final double RESULTS_SPLIT_WEIGHT = 0.65; + private static final double DETAILS_HEIGHT_RATIO = 0.35; + + private final ContentPanelModel contentModel = new ContentPanelModel(); + private final JButton scopeButton = new JButton(); + private final JTextField searchField = new JTextField(); + private final JTree browseTree = new JTree(); + private final DefaultListModel searchListModel = new DefaultListModel<>(); + private final JList searchList = new JList<>(searchListModel); + private final JPanel resultsCards = new JPanel(new CardLayout()); + private final JPanel searchResultsCards = new JPanel(new CardLayout()); + private final JTextPane summaryPane = new JTextPane(); + private final JLabel selectionNameLabel = new JLabel("Select an item."); + private final JButton primaryAction = new JButton("Open"); + private final Icon documentRowIcon = createScaledIcon(ICON_DOCUMENT, 18); + private final Icon templateRowIcon = createScaledIcon(ICON_TEMPLATE, 18); + private final ContentCatalogListener catalogListener = this::refreshContent; + + private BasicEditor editor; + private ContentPanelItem selectedItem; + private ContentScope currentScope = ContentScope.ALL; + private JSplitPane contentSplitPane; + private JComponent detailsComponent; + private int detailsDividerSize; + private boolean detailsCollapsed; + private boolean rebuildingSearchList; + + @Override + public String id() { + return "docs"; + } + + @Override + public String getTitle() { + return "Documentation"; + } + + @Override + public String getActiveIconPath() { + return ICON_MENU_DOCS_SOLID; + } + + @Override + public String getInactiveIconPath() { + return ICON_MENU_DOCS; + } + + @Override + public Color getActiveIconTint() { + return null; + } + + @Override + public EditorLayout getLayoutConstraints() { + return EditorLayout.EAST; + } + + @Override + public JPanel build(PluginContext context) { + if (context instanceof BasicEditor basicEditor) { + this.editor = basicEditor; + this.editor.contentCatalog().addListener(catalogListener); + } + + JPanel panelCardHost = new JPanel(new CardLayout()); + JPanel lookupPanel = new JPanel(new BorderLayout(6, 6)); + Color panelBackground = createLighterPanelBackground(); + panelCardHost.setBackground(panelBackground); + lookupPanel.setBackground(panelBackground); + + configureScopeButton(); + JToggleButton searchToggle = createHeaderSearchToggleButton(); + JPanel lookupHeader = new JPanel(); + lookupHeader.setBackground(panelBackground); + lookupHeader.setLayout(new BoxLayout(lookupHeader, BoxLayout.X_AXIS)); + lookupHeader.add(scopeButton); + lookupHeader.add(Box.createHorizontalGlue()); + lookupHeader.add(searchToggle); + + JPanel searchBar = new JPanel(new BorderLayout(6, 0)); + searchBar.setBackground(panelBackground); + searchBar.setBorder(new EmptyBorder(0, 8, 0, 8)); + searchField.setToolTipText("Search documentation and samples"); + searchBar.add(searchField, BorderLayout.CENTER); + searchBar.setVisible(false); + searchToggle.addActionListener(e -> { + boolean visible = searchToggle.isSelected(); + searchBar.setVisible(visible); + if (visible) { + searchField.requestFocusInWindow(); + } + lookupPanel.revalidate(); + lookupPanel.repaint(); + }); + + configureBrowseTree(panelBackground); + configureSearchList(panelBackground); + JScrollPane browseScrollPane = new JScrollPane(browseTree); + JScrollPane searchScrollPane = new JScrollPane(searchList); + browseScrollPane.setBorder(null); + searchScrollPane.setBorder(null); + configureSmoothScrolling(browseScrollPane); + configureSmoothScrolling(searchScrollPane); + searchResultsCards.setOpaque(false); + searchResultsCards.add(searchScrollPane, "list"); + searchResultsCards.add(createSearchEmptyState(panelBackground), "empty"); + resultsCards.add(browseScrollPane, "browse"); + resultsCards.add(searchResultsCards, "search"); + + JPanel resultsPanel = new JPanel(new BorderLayout(0, 6)); + resultsPanel.setBackground(panelBackground); + JPanel resultsHeader = new JPanel(new BorderLayout(0, 6)); + resultsHeader.setOpaque(false); + resultsHeader.add(lookupHeader, BorderLayout.NORTH); + resultsHeader.add(searchBar, BorderLayout.SOUTH); + resultsPanel.add(resultsHeader, BorderLayout.NORTH); + resultsPanel.add(resultsCards, BorderLayout.CENTER); + + configureSummary(panelBackground); + JScrollPane detailsScrollPane = new JScrollPane(createDetailsPanel(panelBackground)); + detailsScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + detailsScrollPane.setBorder(null); + configureSmoothScrolling(detailsScrollPane); + + contentSplitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + contentSplitPane.setResizeWeight(RESULTS_SPLIT_WEIGHT); + contentSplitPane.setBorder(null); + hideSplitPaneHandle(contentSplitPane); + contentSplitPane.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); + contentSplitPane.putClientProperty("JSplitPane.style", "plain"); + contentSplitPane.setTopComponent(createRoundedCardHost(resultsPanel, panelBackground, "docs-results")); + detailsComponent = createRoundedCardHost(detailsScrollPane, panelBackground, "docs-details"); + contentSplitPane.setBottomComponent(detailsComponent); + detailsDividerSize = contentSplitPane.getDividerSize(); + lookupPanel.add(contentSplitPane, BorderLayout.CENTER); + + searchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + refreshContent(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + refreshContent(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + refreshContent(); + } + }); + + refreshContent(); + panelCardHost.add(lookupPanel, "main"); + ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); + return panelCardHost; + } + + @Override + public void refresh(EditorPlugin languageProvider) { + refreshContent(); + } + + @Override + public void onFileModified(String filePath) {} + + @Override + public void dispose() { + if (editor != null) { + editor.contentCatalog().removeListener(catalogListener); + } + } + + @Override + public void onCompileSucceeded() {} + + private void configureScopeButton() { + scopeButton.setFocusable(false); + scopeButton.setIcon(createScaledIcon(ICON_CHEVRON_DOWN, 18)); + scopeButton.setHorizontalTextPosition(SwingConstants.LEFT); + scopeButton.setIconTextGap(6); + scopeButton.putClientProperty("JButton.buttonType", "toolBarButton"); + scopeButton.setOpaque(false); + scopeButton.setMargin(new Insets(5, 8, 5, 8)); + Font baseFont = scopeButton.getFont(); + scopeButton.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + scopeButton.setForeground(new Color(0x5B717F)); + scopeButton.setToolTipText("Choose content scope"); + updateScopeButtonText(); + scopeButton.addActionListener(e -> createScopePopup().show(scopeButton, 0, scopeButton.getHeight())); + } + + private JPopupMenu createScopePopup() { + JPopupMenu popup = new JPopupMenu(); + for (ContentScope scope : availableScopes()) { + JMenuItem item = new JMenuItem(scope.displayName()); + item.setEnabled(!scope.equals(currentScope)); + item.addActionListener(e -> { + currentScope = scope; + updateScopeButtonText(); + refreshContent(); + }); + popup.add(item); + } + return popup; + } + + private void updateScopeButtonText() { + scopeButton.setText(currentScope.displayName()); + } + + private List availableScopes() { + if (editor == null) { + return List.of(ContentScope.ALL); + } + return contentModel.scopes(editor.contentCatalog()); + } + + private JToggleButton createHeaderSearchToggleButton() { + JToggleButton button = new JToggleButton(createScaledIcon(ICON_SEARCH, 18)); + button.setToolTipText("Show search"); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { + Color cardBackground = createLighterPanelBackground(); + JPanel card = new RoundedCardPanel(); + card.setLayout(new BorderLayout()); + card.setBackground(cardBackground); + card.setBorder(new EmptyBorder(4, 4, 4, 4)); + card.add(content, BorderLayout.CENTER); + + JPanel host = new JPanel(new CardLayout()); + host.setOpaque(false); + host.add(card, key); + ((CardLayout) host.getLayout()).show(host, key); + return host; + } + + private void configureBrowseTree(Color panelBackground) { + browseTree.setBackground(panelBackground); + browseTree.setRootVisible(false); + browseTree.setShowsRootHandles(true); + browseTree.setRowHeight(20); + browseTree.setCellRenderer(new DefaultTreeCellRenderer() { + @Override + public Component getTreeCellRendererComponent( + JTree tree, + Object value, + boolean selected, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus) { + JLabel label = (JLabel) + super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); + Object userObject = value instanceof DefaultMutableTreeNode treeNode ? treeNode.getUserObject() : value; + if (userObject instanceof ContentPanelItem item) { + label.setText(item.displayName()); + label.setIcon(rowIcon(item)); + label.setToolTipText(itemTooltip(item)); + } else { + label.setText(String.valueOf(userObject)); + label.setToolTipText(null); + } + return label; + } + }); + browseTree.addTreeSelectionListener(e -> { + Object selectedNode = browseTree.getLastSelectedPathComponent(); + if (!(selectedNode instanceof DefaultMutableTreeNode treeNode)) { + return; + } + Object selected = treeNode.getUserObject(); + if (selected instanceof ContentPanelItem item) { + selectItem(item); + } + }); + browseTree.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + runPrimaryAction(); + } + } + }); + } + + private void configureSearchList(Color panelBackground) { + searchList.setBackground(panelBackground); + searchList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + searchList.setFixedCellHeight(20); + searchList.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent( + JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + JLabel label = + (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof ContentPanelItem item) { + label.setText(item.displayName()); + label.setIcon(rowIcon(item)); + label.setToolTipText(itemTooltip(item)); + } + return label; + } + }); + searchList.addListSelectionListener(e -> { + if (!e.getValueIsAdjusting() && !rebuildingSearchList) { + selectItem(searchList.getSelectedValue()); + } + }); + searchList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + runPrimaryAction(); + } + } + }); + } + + private Icon rowIcon(ContentPanelItem item) { + return item.template() ? templateRowIcon : documentRowIcon; + } + + private String itemTooltip(ContentPanelItem item) { + String subtitle = item.displaySubtitle(); + return subtitle.isBlank() + ? item.displayName() + : "" + escapeHtml(item.displayName()) + "
" + escapeHtml(subtitle) + ""; + } + + private void configureSummary(Color panelBackground) { + summaryPane.setEditable(false); + summaryPane.setContentType("text/html"); + summaryPane.setBorder(null); + summaryPane.setBackground(panelBackground); + primaryAction.setEnabled(false); + primaryAction.setFocusable(false); + primaryAction.setMargin(new Insets(4, 8, 4, 8)); + primaryAction.setForeground(new Color(0x5B717F)); + primaryAction.addActionListener(e -> runPrimaryAction()); + Font nameFont = selectionNameLabel.getFont(); + selectionNameLabel.setFont(new Font(nameFont.getName(), Font.BOLD, nameFont.getSize())); + int titleHeight = selectionNameLabel.getPreferredSize().height; + selectionNameLabel.setMinimumSize(new Dimension(0, titleHeight)); + selectionNameLabel.setPreferredSize(new Dimension(0, titleHeight)); + setSelectionName("Select an item."); + } + + private JPanel createDetailsPanel(Color panelBackground) { + JPanel detailsPanel = new JPanel(new BorderLayout(0, 0)); + detailsPanel.setBackground(panelBackground); + JPanel detailsHeader = new JPanel(new BorderLayout(8, 0)); + detailsHeader.setBackground(panelBackground); + detailsHeader.setOpaque(true); + detailsHeader.setBorder(new EmptyBorder(6, 8, 4, 8)); + detailsHeader.add(selectionNameLabel, BorderLayout.CENTER); + JPanel detailsActions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); + detailsActions.setOpaque(false); + detailsActions.add(primaryAction); + detailsHeader.add(detailsActions, BorderLayout.EAST); + detailsPanel.add(detailsHeader, BorderLayout.NORTH); + detailsPanel.add(summaryPane, BorderLayout.CENTER); + return detailsPanel; + } + + private JPanel createSearchEmptyState(Color panelBackground) { + JPanel noResultsPanel = new JPanel(new GridBagLayout()); + noResultsPanel.setBackground(panelBackground); + JPanel noResultsContent = new JPanel(); + noResultsContent.setOpaque(false); + noResultsContent.setLayout(new BoxLayout(noResultsContent, BoxLayout.Y_AXIS)); + + Color disabledIconColor = UIManager.getColor("Label.disabledForeground"); + if (disabledIconColor == null) { + disabledIconColor = new Color(0x9E9E9E); + } + JLabel noResultsIcon = new JLabel(createScaledIcon(ICON_SEARCH, 40, disabledIconColor)); + noResultsIcon.setAlignmentX(Component.CENTER_ALIGNMENT); + + JLabel noResultsLabel = new JLabel("No Results"); + Font noResultsFont = noResultsLabel.getFont(); + noResultsLabel.setFont(new Font(noResultsFont.getName(), Font.BOLD, noResultsFont.getSize() + 2)); + noResultsLabel.setForeground(new Color(0x5B717F)); + noResultsLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + + JButton resetFiltersButton = new JButton("Reset Filters"); + resetFiltersButton.setFocusable(false); + resetFiltersButton.setMargin(new Insets(4, 4, 4, 4)); + resetFiltersButton.setForeground(new Color(0x5B717F)); + resetFiltersButton.setAlignmentX(Component.CENTER_ALIGNMENT); + resetFiltersButton.addActionListener(e -> resetSearchFilters()); + + noResultsContent.add(noResultsIcon); + noResultsContent.add(Box.createVerticalStrut(8)); + noResultsContent.add(noResultsLabel); + noResultsContent.add(Box.createVerticalStrut(8)); + noResultsContent.add(resetFiltersButton); + noResultsPanel.add(noResultsContent); + return noResultsPanel; + } + + private void resetSearchFilters() { + currentScope = ContentScope.ALL; + updateScopeButtonText(); + searchField.setText(""); + refreshContent(); + } + + private void refreshContent() { + if (editor == null) { + setSummary(null); + return; + } + ensureCurrentScopeAvailable(); + String query = + searchField.getText() == null ? "" : searchField.getText().trim(); + if (query.isBlank()) { + ContentBrowseNode root = browseForCurrentScope(); + DefaultMutableTreeNode rootNode = toTreeNode(root); + browseTree.setModel(new DefaultTreeModel(rootNode)); + browseTree.expandPath(new TreePath(rootNode.getPath())); + browseTree.expandRow(0); + ((CardLayout) resultsCards.getLayout()).show(resultsCards, "browse"); + } else { + rebuildingSearchList = true; + try { + searchListModel.clear(); + for (ContentPanelItem item : contentModel.items(editor.contentCatalog(), currentScope, query)) { + searchListModel.addElement(item); + } + if (!searchListModel.isEmpty()) { + searchList.setSelectedIndex(0); + } + } finally { + rebuildingSearchList = false; + } + + ((CardLayout) searchResultsCards.getLayout()) + .show(searchResultsCards, searchListModel.isEmpty() ? "empty" : "list"); + selectItem(searchList.getSelectedValue()); + ((CardLayout) resultsCards.getLayout()).show(resultsCards, "search"); + } + setSummary(selectedItem); + } + + private ContentBrowseNode browseForCurrentScope() { + return contentModel.browse(editor.contentCatalog(), currentScope, this::categoryPathForCurrentScope); + } + + private List categoryPathForCurrentScope(ContentPanelItem item) { + List categoryPath = item.categoryPath(); + if (currentScope.all() || categoryPath.isEmpty()) { + return categoryPath; + } + return categoryPath.stream() + .filter(segment -> !isRedundantScopeSegment(segment)) + .toList(); + } + + private boolean isRedundantScopeSegment(String segment) { + String normalizedSegment = normalizeScopeText(segment); + String normalizedTag = currentScope.tag(); + return normalizedTag != null + && (normalizedSegment.equals(normalizedTag) + || normalizedSegment.equals(normalizedTag + "s") + || normalizedSegment.equals(normalizeScopeText(currentScope.displayName()))); + } + + private String normalizeScopeText(String text) { + return text == null + ? "" + : text.trim() + .toLowerCase(Locale.ROOT) + .replace('_', '-') + .replace(' ', '-'); + } + + private void ensureCurrentScopeAvailable() { + if (currentScope.all() || availableScopes().contains(currentScope)) { + updateScopeButtonText(); + return; + } + currentScope = ContentScope.ALL; + updateScopeButtonText(); + } + + private DefaultMutableTreeNode toTreeNode(ContentBrowseNode node) { + DefaultMutableTreeNode treeNode = new DefaultMutableTreeNode(node.name()); + for (ContentBrowseNode child : node.children()) { + treeNode.add(toTreeNode(child)); + } + for (ContentPanelItem item : node.items()) { + treeNode.add(new DefaultMutableTreeNode(item)); + } + return treeNode; + } + + private void selectItem(ContentPanelItem item) { + selectedItem = item; + setSummary(item); + } + + private void setSummary(ContentPanelItem item) { + if (item == null) { + setDetailsVisible(false); + setSelectionName("Select an item."); + summaryPane.setText("Select an item."); + primaryAction.setText("Open"); + primaryAction.setEnabled(false); + return; + } + setDetailsVisible(true); + ContentSelectionSummary summary = contentModel.summary(item); + setSelectionName(summary.title()); + summaryPane.setText("" + + "

" + escapeHtml(summary.kindLabel()) + "

" + + "

" + escapeHtml(summary.description()) + "

" + + "

Category: " + escapeHtml(summary.category()) + "

" + + relatedHtml(summary.relatedIds()) + + ""); + summaryPane.setCaretPosition(0); + primaryAction.setText(summary.primaryAction()); + primaryAction.setEnabled(true); + } + + private void setDetailsVisible(boolean visible) { + if (contentSplitPane == null || detailsComponent == null) { + return; + } + + if (visible) { + if (detailsCollapsed || contentSplitPane.getBottomComponent() != detailsComponent) { + expandDetailsPane(); + } + } else if (!detailsCollapsed || contentSplitPane.getBottomComponent() == detailsComponent) { + collapseDetailsPane(); + } + } + + private void collapseDetailsPane() { + detailsCollapsed = true; + contentSplitPane.setResizeWeight(1.0); + contentSplitPane.setDividerSize(0); + contentSplitPane.setBottomComponent(null); + contentSplitPane.revalidate(); + contentSplitPane.repaint(); + } + + private void expandDetailsPane() { + detailsCollapsed = false; + if (contentSplitPane.getBottomComponent() != detailsComponent) { + contentSplitPane.setBottomComponent(detailsComponent); + } + contentSplitPane.setDividerSize(detailsDividerSize); + contentSplitPane.setResizeWeight(RESULTS_SPLIT_WEIGHT); + contentSplitPane.revalidate(); + + SwingUtilities.invokeLater(() -> { + if (detailsCollapsed || contentSplitPane.getBottomComponent() != detailsComponent) { + return; + } + + int splitPaneHeight = contentSplitPane.getHeight(); + if (splitPaneHeight <= 0) { + return; + } + + int availableHeight = Math.max(0, splitPaneHeight - contentSplitPane.getDividerSize()); + int minimumTopHeight = Math.min(80, availableHeight); + int maximumDetailsHeight = Math.max(0, availableHeight - minimumTopHeight); + int targetDetailsHeight = Math.max(140, + (int) Math.round(availableHeight * DETAILS_HEIGHT_RATIO)); + targetDetailsHeight = Math.min(targetDetailsHeight, maximumDetailsHeight); + + contentSplitPane.setDividerLocation(availableHeight - targetDetailsHeight); + }); + } + + private void setSelectionName(String name) { + String text = name == null ? "" : name; + selectionNameLabel.setText(text); + selectionNameLabel.setToolTipText(text.isBlank() ? null : text); + } + + private String relatedHtml(List relatedIds) { + if (relatedIds == null || relatedIds.isEmpty()) { + return ""; + } + return "

Related: " + escapeHtml(String.join(", ", relatedIds)) + "

"; + } + + private void runPrimaryAction() { + if (editor == null || selectedItem == null) { + return; + } + if (!selectedItem.template()) { + editor.contentCatalog().documents().stream() + .filter(entry -> entry.globalId().value().equals(selectedItem.globalId())) + .findFirst() + .ifPresent(editor::openContentDocument); + return; + } + editor.contentCatalog().templates().stream() + .filter(entry -> entry.globalId().value().equals(selectedItem.globalId())) + .findFirst() + .ifPresent(this::instantiateTemplate); + } + + private void instantiateTemplate(TemplateCatalogEntry entry) { + JFileChooser chooser = new JFileChooser(editor.currentDirectory()); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + chooser.setDialogTitle("Choose destination folder"); + int result = chooser.showSaveDialog(SwingUtilities.getWindowAncestor(primaryAction)); + if (result != JFileChooser.APPROVE_OPTION) { + return; + } + Path destination = chooser.getSelectedFile().toPath(); + editor.instantiateTemplate(entry, destination, entry.descriptor().title()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/basic4gl/desktop/panels/EditorLayout.java b/app/src/main/java/com/basic4gl/desktop/panels/EditorLayout.java new file mode 100644 index 00000000..f1c5e4fb --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/EditorLayout.java @@ -0,0 +1,7 @@ +package com.basic4gl.desktop.panels; + +public enum EditorLayout { + EAST, + WEST, + SOUTH +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java new file mode 100644 index 00000000..2d28568e --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java @@ -0,0 +1,418 @@ +package com.basic4gl.desktop.panels; + +import static com.basic4gl.desktop.Theme.*; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; +import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; +import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; + +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.util.FileUtil; +import com.basic4gl.desktop.util.RoundedCardPanel; +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Locale; +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.filechooser.FileSystemView; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; + +public class FileBrowserPanelProvider implements IEditorPanelProvider { + + private PluginContext context; + + private final JTree fileBrowserTree = new JTree(); + private final JTextField fileSearchField = new JTextField(); + private final FileSystemView fileSystemView = FileSystemView.getFileSystemView(); + private final JPanel searchResultsCards = new JPanel(new CardLayout()); + private boolean showHiddenFiles = false; + private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); + + @Override + public String id() { + return "files"; + } + + @Override + public String getTitle() { + return "Workspace"; + } + + @Override + public String getActiveIconPath() { + return ICON_MENU_FOLDER_SOLID; + } + + @Override + public String getInactiveIconPath() { + return ICON_MENU_FOLDER; + } + + @Override + public Color getActiveIconTint() { + return null; + } + + @Override + public EditorLayout getLayoutConstraints() { + return EditorLayout.WEST; + } + + @Override + public JPanel build(PluginContext context) { + this.context = context; + JPanel panelCardHost = new JPanel(new CardLayout()); + JPanel panel = new JPanel(new BorderLayout(0, 6)); + Color panelBackground = createLighterPanelBackground(); + panel.setBackground(panelBackground); + panel.setOpaque(true); + JPanel header = new JPanel(); + header.setLayout(new BoxLayout(header, BoxLayout.X_AXIS)); + header.setBackground(panelBackground); + header.setOpaque(true); + JLabel title = new JLabel("Workspace"); + Font baseFont = title.getFont(); + title.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + title.setForeground(new Color(0x424242)); + title.setBorder(new EmptyBorder(0, 8, 0, 8)); + JButton refresh = createHeaderIconButton(ICON_REFRESH, "Refresh Workspace"); + refresh.addActionListener(e -> refresh(context.currentEditor())); + JToggleButton searchToggle = createHeaderSearchToggleButton(); + + JPopupMenu overflowMenu = new JPopupMenu(); + JMenuItem openFolderItem = new JMenuItem("Open Folder"); + openFolderItem.addActionListener(e -> context.commands().actionOpenFolder()); + overflowMenu.add(openFolderItem); + + JCheckBoxMenuItem showHiddenItem = new JCheckBoxMenuItem("Show Hidden Files", showHiddenFiles); + showHiddenItem.addActionListener(e -> { + showHiddenFiles = showHiddenItem.isSelected(); + refresh(context.currentEditor()); + }); + overflowMenu.add(showHiddenItem); + + JButton overflowButton = createHeaderIconButton(ICON_DOTS_VERTICAL, "More Actions"); + overflowButton.addActionListener(e -> { + showHiddenItem.setSelected(showHiddenFiles); + overflowMenu.show(overflowButton, 0, overflowButton.getHeight()); + }); + + header.add(title); + header.add(Box.createHorizontalGlue()); + header.add(searchToggle); + header.add(refresh); + header.add(overflowButton); + panel.add(header, BorderLayout.NORTH); + + JPanel searchBar = new JPanel(new BorderLayout(6, 0)); + searchBar.setBackground(panelBackground); + searchBar.setBorder(new EmptyBorder(0, 8, 0, 8)); + fileSearchField.setToolTipText("Search workspace files"); + searchBar.add(fileSearchField, BorderLayout.CENTER); + searchBar.setVisible(false); + fileSearchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + refresh(context.currentEditor()); + } + + @Override + public void removeUpdate(DocumentEvent e) { + refresh(context.currentEditor()); + } + + @Override + public void changedUpdate(DocumentEvent e) { + refresh(context.currentEditor()); + } + }); + searchToggle.addActionListener(e -> { + boolean visible = searchToggle.isSelected(); + searchBar.setVisible(visible); + if (visible) { + fileSearchField.requestFocusInWindow(); + } + panel.revalidate(); + panel.repaint(); + }); + + fileBrowserTree.setBackground(panelBackground); + fileBrowserTree.setRootVisible(true); + fileBrowserTree.setShowsRootHandles(true); + fileBrowserTree.setRowHeight(22); + fileBrowserTree.setCellRenderer(new DefaultTreeCellRenderer() { + @Override + public Component getTreeCellRendererComponent( + JTree tree, + Object value, + boolean selected, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus) { + JLabel label = (JLabel) + super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); + if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof File file) { + label.setText(file == null ? "" : fileSystemView.getSystemDisplayName(file)); + if (label.getText() == null || label.getText().isBlank()) { + label.setText(file.getName().isBlank() ? file.getPath() : file.getName()); + } + label.setIcon(fileSystemView.getSystemIcon(file)); + label.setToolTipText(file.getAbsolutePath()); + boolean isHidden = file.getName().startsWith("."); + if (isHidden && !selected) { + label.setForeground(new Color(160, 160, 160)); + } + } + return label; + } + }); + fileBrowserTree.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + maybeShowWorkspaceBrowserPopup(e); + if (e.getClickCount() != 2) { + return; + } + TreePath path = fileBrowserTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof File file) || !file.isFile()) { + return; + } + + context.commands().openFileWithPreferredViewer(file); + } + + @Override + public void mousePressed(MouseEvent e) { + maybeShowWorkspaceBrowserPopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybeShowWorkspaceBrowserPopup(e); + } + }); + JScrollPane scrollPane = new JScrollPane(fileBrowserTree); + scrollPane.setBorder(null); + scrollPane.setBackground(panelBackground); + + configureSmoothScrolling(scrollPane); + searchResultsCards.setOpaque(false); + searchResultsCards.add(scrollPane, "tree"); + searchResultsCards.add(createSearchEmptyState(panelBackground), "empty"); + JPanel content = new JPanel(new BorderLayout(0, 6)); + content.setBackground(panelBackground); + content.add(searchBar, BorderLayout.NORTH); + content.add(searchResultsCards, BorderLayout.CENTER); + panel.add(content, BorderLayout.CENTER); + panelCardHost.add(createRoundedCardHost(panel, panelBackground, "workspace-main"), "main"); + ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); + return panelCardHost; + } + + private JPanel createSearchEmptyState(Color panelBackground) { + JPanel noResultsPanel = new JPanel(new GridBagLayout()); + noResultsPanel.setBackground(panelBackground); + JPanel noResultsContent = new JPanel(); + noResultsContent.setOpaque(false); + noResultsContent.setLayout(new BoxLayout(noResultsContent, BoxLayout.Y_AXIS)); + + Color disabledIconColor = UIManager.getColor("Label.disabledForeground"); + if (disabledIconColor == null) { + disabledIconColor = new Color(0x9E9E9E); + } + JLabel noResultsIcon = new JLabel(createScaledIcon(ICON_SEARCH, 40, disabledIconColor)); + noResultsIcon.setAlignmentX(Component.CENTER_ALIGNMENT); + + JLabel noResultsLabel = new JLabel("No Results"); + Font noResultsFont = noResultsLabel.getFont(); + noResultsLabel.setFont(new Font(noResultsFont.getName(), Font.BOLD, noResultsFont.getSize() + 2)); + noResultsLabel.setForeground(new Color(0x5B717F)); + noResultsLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + + JButton resetFiltersButton = new JButton("Reset Filters"); + resetFiltersButton.setFocusable(false); + resetFiltersButton.setMargin(new Insets(4, 4, 4, 4)); + resetFiltersButton.setForeground(new Color(0x5B717F)); + resetFiltersButton.setAlignmentX(Component.CENTER_ALIGNMENT); + resetFiltersButton.addActionListener(e -> { + fileSearchField.setText(""); + refresh(context.currentEditor()); + }); + + noResultsContent.add(noResultsIcon); + noResultsContent.add(Box.createVerticalStrut(8)); + noResultsContent.add(noResultsLabel); + noResultsContent.add(Box.createVerticalStrut(8)); + noResultsContent.add(resetFiltersButton); + noResultsPanel.add(noResultsContent); + return noResultsPanel; + } + + private JButton createHeaderIconButton(String iconPath, String tooltip) { + JButton button = new JButton(createScaledIcon(iconPath, 18)); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private JToggleButton createHeaderSearchToggleButton() { + JToggleButton button = new JToggleButton(createScaledIcon(ICON_SEARCH, 18)); + button.setToolTipText("Show search"); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { + Color cardBackground = createLighterPanelBackground(); + JPanel card = new RoundedCardPanel(); + card.setLayout(new BorderLayout()); + card.setBackground(cardBackground); + card.setBorder(new EmptyBorder(4, 4, 4, 4)); + card.add(content, BorderLayout.CENTER); + + JPanel host = new JPanel(new CardLayout()); + host.setBackground(panelBackground); + host.setOpaque(false); + host.add(card, key); + ((CardLayout) host.getLayout()).show(host, key); + return host; + } + + private void maybeShowWorkspaceBrowserPopup(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + + TreePath path = fileBrowserTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + fileBrowserTree.setSelectionPath(path); + + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof File selectedFile)) { + return; + } + + JPopupMenu popup = new JPopupMenu(); + + JMenuItem openItem = new JMenuItem(selectedFile.isDirectory() ? "Open Folder" : "Open"); + openItem.addActionListener(evt -> { + if (selectedFile.isDirectory()) { + context.commands().setWorkspaceDirectory(selectedFile); + } else { + context.commands().openFileWithPreferredViewer(selectedFile); + } + }); + popup.add(openItem); + + JMenuItem revealItem = new JMenuItem("Reveal in Finder"); + revealItem.addActionListener(evt -> FileUtil.revealInFinder(selectedFile, context.dialogs())); + popup.add(revealItem); + + JMenuItem openSystemItem = new JMenuItem("Open with Default App"); + openSystemItem.addActionListener(evt -> FileUtil.openWithSystemDefault(selectedFile, context.dialogs())); + popup.add(openSystemItem); + + JMenuItem copyPathItem = new JMenuItem("Copy Path"); + copyPathItem.addActionListener(evt -> { + StringSelection selection = new StringSelection(selectedFile.getAbsolutePath()); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + }); + popup.add(copyPathItem); + + popup.addSeparator(); + JMenuItem refreshItem = new JMenuItem("Refresh"); + refreshItem.addActionListener(evt -> refresh(context.currentEditor())); + popup.add(refreshItem); + + popup.show(fileBrowserTree, e.getX(), e.getY()); + } + + @Override + public void refresh(EditorPlugin languageProvider) { + if (context == null) { + return; + } + File root = new File(context.currentDirectory()); + String searchNeedle = fileSearchField.getText() == null + ? "" + : fileSearchField.getText().trim().toLowerCase(Locale.ROOT); + DefaultMutableTreeNode rootNode = buildFileTreeNode(root, 0, searchNeedle); + if (rootNode == null) { + rootNode = new DefaultMutableTreeNode(root); + } + fileBrowserTree.setModel(new DefaultTreeModel(rootNode)); + boolean hasSearch = searchNeedle != null && !searchNeedle.isBlank(); + ((CardLayout) searchResultsCards.getLayout()) + .show(searchResultsCards, hasSearch && rootNode.getChildCount() == 0 ? "empty" : "tree"); + if (fileBrowserTree.getRowCount() > 0) { + fileBrowserTree.expandRow(0); + } + } + + @Override + public void onFileModified(String filePath) {} + + @Override + public void dispose() {} + + @Override + public void onCompileSucceeded() {} + + private DefaultMutableTreeNode buildFileTreeNode(File file, int depth, String searchNeedle) { + boolean hasSearch = searchNeedle != null && !searchNeedle.isBlank(); + boolean nameMatches = + !hasSearch || file.getName().toLowerCase(Locale.ROOT).contains(searchNeedle); + boolean pathMatches = + !hasSearch || file.getAbsolutePath().toLowerCase(Locale.ROOT).contains(searchNeedle); + boolean matches = nameMatches || pathMatches; + if (!file.isDirectory()) { + return matches ? new DefaultMutableTreeNode(file) : null; + } + + DefaultMutableTreeNode node = new DefaultMutableTreeNode(file); + File[] children = file.listFiles(); + if (children == null) { + return (depth == 0 || matches) ? node : null; + } + Arrays.sort(children, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + for (File child : children) { + if (!showHiddenFiles && child.getName().startsWith(".")) { + continue; + } + DefaultMutableTreeNode childNode = buildFileTreeNode(child, depth + 1, searchNeedle); + if (childNode != null) { + node.add(childNode); + } + } + return (depth == 0 || matches || node.getChildCount() > 0) ? node : null; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java new file mode 100644 index 00000000..5c09a8d9 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java @@ -0,0 +1,34 @@ +package com.basic4gl.desktop.panels; + +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.PluginContext; +import java.awt.*; +import javax.swing.*; + +public interface IEditorPanelProvider { + String id(); + + String getTitle(); + + String getActiveIconPath(); + + String getInactiveIconPath(); + + Color getActiveIconTint(); + + EditorLayout getLayoutConstraints(); + + JPanel build(PluginContext context); + + void refresh(EditorPlugin languageProvider); + + void onFileModified(String filePath); + + void dispose(); + // + // void onTabClosed(); + // TODO cleanup hooks; this should be a separate listener that can be registered with PluginContext from build() + void onCompileSucceeded(); + // + +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java new file mode 100644 index 00000000..d1794633 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java @@ -0,0 +1,914 @@ +package com.basic4gl.desktop.panels; + +import static com.basic4gl.desktop.Theme.*; +import static com.basic4gl.desktop.Theme.ICON_MENU_FUNCTIONS; +import static com.basic4gl.desktop.Theme.ICON_STRUCT; +import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; +import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; +import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; + +import com.basic4gl.desktop.language.SymbolIndexer; +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.LanguageService; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.language.FunctionDefinition; +import com.basic4gl.desktop.spi.language.IndexedSymbol; +import com.basic4gl.desktop.spi.language.LabelDefinition; +import com.basic4gl.desktop.spi.language.VariableDefinition; +import com.basic4gl.desktop.util.RoundedCardPanel; +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.*; +import java.util.List; +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +public class SymbolsPanelProvider implements IEditorPanelProvider { + + private static final String REFERENCE_NO_MATCHES_HTML = + "No matches."; + private static final String REFERENCE_SELECT_PROMPT_HTML = + "Select an entry."; + private String referenceDetailsHtml = REFERENCE_SELECT_PROMPT_HTML; + private final JComboBox referenceLibraryFilter = new JComboBox<>(new String[] {"All libraries"}); + private final JButton referenceFiltersButton = new JButton("All"); + private final JPopupMenu referenceFiltersPopup = new JPopupMenu(); + private final JLabel referenceSelectionNameLabel = new JLabel("Select an entry."); + private String referenceSelectionName = "Select an entry."; + private final JTextPane referenceDetailsPane = new JTextPane(); + private final JButton referenceCopyButton = new JButton("Copy"); + private final JButton referenceInsertButton = new JButton("Insert"); + private final javax.swing.Timer referenceFilterDebounceTimer = + new javax.swing.Timer(120, e -> filterReferenceItems()); + private final JPanel referenceResultsHost = new JPanel(new CardLayout()); + private static final String REFERENCE_RESULTS_LIST_CARD = "list"; + private static final String REFERENCE_RESULTS_EMPTY_CARD = "empty"; + + private SymbolIndexer symbolIndexer; + private final java.util.List allReferenceItems = new ArrayList<>(); + private final DefaultListModel referenceListModel = new DefaultListModel<>(); + private final JList referenceList = new JList<>(referenceListModel); + private final JTextField referenceSearchField = new JTextField(); + private final JComboBox referenceKindFilter = + new JComboBox<>(new String[] {"All", "Functions", "Constants", "Labels", "Variables", "Structs"}); + private final JComboBox referenceSourceFilter = + new JComboBox<>(new String[] {"All Sources", "Builtin", "Libraries", "Program"}); + private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); + private static final int CARD_ARC = 14; + private static final double RESULTS_SPLIT_WEIGHT = 0.65; + private static final double DETAILS_HEIGHT_RATIO = 0.35; + + private int lastProgramSymbolsFingerprint = Integer.MIN_VALUE; + private boolean updatingReferenceFilters = false; + private JSplitPane referenceSplitPane; + private JComponent referenceDetailsComponent; + private int referenceDetailsDividerSize; + private boolean referenceDetailsCollapsed; + private boolean rebuildingReferenceList; + + private PluginContext context; + + private static final class ReferenceItem { + final String kind; + final String name; + final String signature; + final String library; + final String details; + final String insertText; + final int caretOffset; + + ReferenceItem( + String kind, + String name, + String signature, + String library, + String details, + String insertText, + int caretOffset) { + this.kind = kind; + this.name = name; + this.signature = signature; + this.library = library; + this.details = details; + this.insertText = insertText; + this.caretOffset = caretOffset; + } + + @Override + public String toString() { + return signature; + } + } + + @Override + public String id() { + return "symbols"; + } + + @Override + public String getTitle() { + return "Code Reference"; + } + + @Override + public String getActiveIconPath() { + return ICON_MENU_FUNCTIONS; + } + + @Override + public String getInactiveIconPath() { + return ICON_MENU_FUNCTIONS; + } + + @Override + public Color getActiveIconTint() { + return null; + } + + @Override + public EditorLayout getLayoutConstraints() { + return EditorLayout.EAST; + } + + public JPanel build(PluginContext context) { + this.context = context; + + symbolIndexer = new SymbolIndexer( + context.currentEditor().getLanguageSupport(), + context.commands()::collectAllSourceText, + this::updateProgramSymbols); + JPanel panelCardHost = new JPanel(new CardLayout()); + JPanel lookupPanel = new JPanel(new BorderLayout(6, 6)); + Color panelBackground = createLighterPanelBackground(); + lookupPanel.setBackground(panelBackground); + JPanel lookupHeader = new JPanel(); + lookupHeader.setBackground(panelBackground); + lookupHeader.setLayout(new BoxLayout(lookupHeader, BoxLayout.X_AXIS)); + + referenceFiltersButton.setFocusable(false); + referenceFiltersButton.setIcon(createScaledIcon(ICON_CHEVRON_DOWN, 18)); + referenceFiltersButton.setHorizontalTextPosition(SwingConstants.LEFT); + referenceFiltersButton.setIconTextGap(6); + referenceFiltersButton.putClientProperty("JButton.buttonType", "toolBarButton"); + referenceFiltersButton.setOpaque(false); + referenceFiltersButton.setMargin(new Insets(5, 8, 5, 8)); + Font baseFont = referenceFiltersButton.getFont(); + referenceFiltersButton.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + referenceFiltersButton.setForeground(new Color(0x5B717F)); + referenceFiltersButton.setToolTipText("Open reference filters"); + lookupHeader.add(referenceFiltersButton); + lookupHeader.add(Box.createHorizontalGlue()); + JToggleButton searchToggle = createHeaderSearchToggleButton(); + lookupHeader.add(searchToggle); + + referenceCopyButton.setFocusable(false); + referenceCopyButton.setMargin(new Insets(4, 4, 4, 4)); + Font actionButtonFont = referenceCopyButton.getFont(); + // referenceCopyButton.setFont(new Font(actionButtonFont.getName(), Font.BOLD, + // actionButtonFont.getSize())); + referenceCopyButton.setForeground(new Color(0x5B717F)); + referenceCopyButton.setEnabled(false); + referenceInsertButton.setFocusable(false); + referenceInsertButton.setMargin(new Insets(4, 4, 4, 4)); + // referenceInsertButton.setFont(new Font(actionButtonFont.getName(), Font.BOLD, + // actionButtonFont.getSize())); + referenceInsertButton.setForeground(new Color(0x5B717F)); + referenceInsertButton.setEnabled(false); + + referenceSearchField.setToolTipText("Search by name, signature, or library"); + referenceKindFilter.setToolTipText("Filter by kind"); + referenceSourceFilter.setToolTipText("Filter by builtin, libraries, or program symbols"); + referenceLibraryFilter.setToolTipText("Filter by library name"); + referenceKindFilter.setPrototypeDisplayValue("Functions"); + rebuildReferenceFiltersPopup(); + + referenceFilterDebounceTimer.setRepeats(false); + + referenceList.setBackground(panelBackground); + referenceList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + referenceList.setFixedCellHeight(20); + referenceList.setPrototypeCellValue(new SymbolsPanelProvider.ReferenceItem( + "function", "prototype", "prototype(symbol, arg)", "Builtin", "", "", 0)); + referenceList.setCellRenderer(new DefaultListCellRenderer() { + private final ImageIcon functionIcon = createImageIcon(ICON_FUNCTION); + private final ImageIcon variableIcon = createImageIcon(ICON_VARIABLE); + private final ImageIcon labelIcon = createImageIcon(ICON_LABEL); + private final ImageIcon structIcon = createImageIcon(ICON_STRUCT); + + @Override + public Component getListCellRendererComponent( + JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + JLabel label = + (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof SymbolsPanelProvider.ReferenceItem item) { + label.setText(item.signature); + if ("function".equals(item.kind) || "userfunc".equals(item.kind)) { + label.setIcon(functionIcon); + } else if ("label".equals(item.kind)) { + label.setIcon(labelIcon); + } else if ("struc".equals(item.kind)) { + label.setIcon(structIcon); + } else { + label.setIcon(variableIcon); + } + label.setToolTipText(null); + } + return label; + } + }); + + referenceDetailsPane.setEditable(false); + referenceDetailsPane.setContentType("text/html"); + referenceDetailsPane.setBorder(null); + referenceDetailsPane.setBackground(panelBackground); + setReferenceDetailsHtml(REFERENCE_SELECT_PROMPT_HTML); + Font nameFont = referenceSelectionNameLabel.getFont(); + referenceSelectionNameLabel.setFont(new Font(nameFont.getName(), Font.BOLD, nameFont.getSize())); + int titleHeight = referenceSelectionNameLabel.getPreferredSize().height; + referenceSelectionNameLabel.setMinimumSize(new Dimension(0, titleHeight)); + referenceSelectionNameLabel.setPreferredSize(new Dimension(0, titleHeight)); + setReferenceSelectionName("Select an entry."); + + referenceSplitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + referenceSplitPane.setResizeWeight(RESULTS_SPLIT_WEIGHT); + hideSplitPaneHandle(referenceSplitPane); + referenceSplitPane.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); + referenceSplitPane.putClientProperty("JSplitPane.style", "plain"); + JScrollPane listScrollPane = new JScrollPane(referenceList); + listScrollPane.setBorder(null); + JPanel noResultsPanel = new JPanel(new GridBagLayout()); + noResultsPanel.setBackground(panelBackground); + JPanel noResultsContent = new JPanel(); + noResultsContent.setOpaque(false); + noResultsContent.setLayout(new BoxLayout(noResultsContent, BoxLayout.Y_AXIS)); + Color disabledIconColor = UIManager.getColor("Label.disabledForeground"); + if (disabledIconColor == null) { + disabledIconColor = new Color(0xC8C8C8); + } + JLabel noResultsIcon = new JLabel(createScaledIcon(ICON_SEARCH, 40, disabledIconColor)); + noResultsIcon.setAlignmentX(Component.CENTER_ALIGNMENT); + JLabel noResultsLabel = new JLabel("No Results"); + Font noResultsFont = noResultsLabel.getFont(); + noResultsLabel.setFont(new Font(noResultsFont.getName(), Font.BOLD, noResultsFont.getSize() + 2)); + noResultsLabel.setForeground(new Color(0x5B717F)); + noResultsLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + JButton resetFiltersButton = new JButton("Reset Filters"); + resetFiltersButton.setFocusable(false); + resetFiltersButton.setMargin(new Insets(4, 4, 4, 4)); + resetFiltersButton.setForeground(new Color(0x5B717F)); + resetFiltersButton.setAlignmentX(Component.CENTER_ALIGNMENT); + resetFiltersButton.addActionListener(e -> resetReferenceFiltersAndSearch()); + noResultsContent.add(noResultsIcon); + noResultsContent.add(Box.createVerticalStrut(8)); + noResultsContent.add(noResultsLabel); + noResultsContent.add(Box.createVerticalStrut(8)); + noResultsContent.add(resetFiltersButton); + noResultsPanel.add(noResultsContent); + referenceResultsHost.setOpaque(false); + referenceResultsHost.add(listScrollPane, REFERENCE_RESULTS_LIST_CARD); + referenceResultsHost.add(noResultsPanel, REFERENCE_RESULTS_EMPTY_CARD); + JPanel detailsPanel = new JPanel(new BorderLayout(0, 0)); + JPanel detailsHeader = new JPanel(new BorderLayout(8, 0)); + detailsHeader.setBackground(panelBackground); + detailsHeader.setOpaque(true); + detailsHeader.setBorder(new EmptyBorder(6, 8, 4, 8)); + detailsHeader.add(referenceSelectionNameLabel, BorderLayout.CENTER); + JPanel detailsActions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); + detailsActions.setOpaque(false); + detailsActions.add(referenceCopyButton); + detailsActions.add(referenceInsertButton); + detailsHeader.add(detailsActions, BorderLayout.EAST); + detailsPanel.add(detailsHeader, BorderLayout.NORTH); + detailsPanel.add(referenceDetailsPane, BorderLayout.CENTER); + JScrollPane detailsScrollPane = new JScrollPane(detailsPanel); + detailsScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + detailsScrollPane.setBorder(null); + referenceDetailsComponent = createRoundedCardHost(detailsScrollPane, panelBackground, "symbols-details"); + referenceSplitPane.setBottomComponent(referenceDetailsComponent); + + JPanel searchBar = new JPanel(new BorderLayout(6, 0)); + searchBar.setBackground(panelBackground); + searchBar.setBorder(new EmptyBorder(0, 8, 0, 8)); + searchBar.add(referenceSearchField, BorderLayout.CENTER); + searchBar.setVisible(false); + searchToggle.addActionListener(e -> { + boolean visible = searchToggle.isSelected(); + searchBar.setVisible(visible); + if (visible) { + referenceSearchField.requestFocusInWindow(); + } + lookupPanel.revalidate(); + lookupPanel.repaint(); + }); + JPanel symbolsListPanel = new JPanel(new BorderLayout(0, 6)); + symbolsListPanel.setBackground(panelBackground); + JPanel symbolsListHeader = new JPanel(new BorderLayout(0, 6)); + symbolsListHeader.setOpaque(false); + symbolsListHeader.add(lookupHeader, BorderLayout.NORTH); + symbolsListHeader.add(searchBar, BorderLayout.SOUTH); + symbolsListPanel.add(symbolsListHeader, BorderLayout.NORTH); + symbolsListPanel.add(referenceResultsHost, BorderLayout.CENTER); + referenceSplitPane.setTopComponent(createRoundedCardHost(symbolsListPanel, panelBackground, "symbols-list")); + referenceDetailsDividerSize = referenceSplitPane.getDividerSize(); + + lookupPanel.add(referenceSplitPane, BorderLayout.CENTER); + + referenceSearchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + requestFilterReferenceItems(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + requestFilterReferenceItems(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + requestFilterReferenceItems(); + } + }); + referenceList.addListSelectionListener(e -> { + if (!e.getValueIsAdjusting() && !rebuildingReferenceList) { + updateReferenceSelectionDetails(); + } + }); + referenceKindFilter.addActionListener(e -> { + if (!updatingReferenceFilters) { + updateReferenceFiltersButtonTooltip(); + filterReferenceItems(); + } + }); + referenceSourceFilter.addActionListener(e -> { + if (!updatingReferenceFilters) { + updateReferenceFiltersButtonTooltip(); + filterReferenceItems(); + } + }); + referenceLibraryFilter.addActionListener(e -> { + if (!updatingReferenceFilters) { + updateReferenceFiltersButtonTooltip(); + filterReferenceItems(); + } + }); + referenceFiltersButton.addActionListener(e -> { + rebuildReferenceFiltersPopup(); + referenceFiltersPopup.show(referenceFiltersButton, 0, referenceFiltersButton.getHeight()); + }); + referenceList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + insertSelectedReference(); + } + } + }); + referenceInsertButton.addActionListener(e -> insertSelectedReference()); + referenceCopyButton.addActionListener(e -> copySelectedSymbolName()); + updateReferenceFiltersButtonTooltip(); + setReferenceDetailsVisible(false); + + panelCardHost.add(lookupPanel, "main"); + ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); + return panelCardHost; + } + + private JToggleButton createHeaderSearchToggleButton() { + JToggleButton button = new JToggleButton(createScaledIcon(ICON_SEARCH, 18)); + button.setToolTipText("Show search"); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { + Color cardBackground = createLighterPanelBackground(); + // new Color( + // Math.min(255, panelBackground.getRed() + 10), + // Math.min(255, panelBackground.getGreen() + 10), + // Math.min(255, panelBackground.getBlue() + 10)); + JPanel card = new RoundedCardPanel(); + card.setLayout(new BorderLayout()); + card.setBackground(cardBackground); + card.setBorder(new EmptyBorder(4, 4, 4, 4)); + card.add(content, BorderLayout.CENTER); + + JPanel host = new JPanel(new CardLayout()); + host.setOpaque(false); + host.add(card, key); + ((CardLayout) host.getLayout()).show(host, key); + return host; + } + + @Override + public void refresh(EditorPlugin languageProvider) { + if (context == null) { + return; + } + populateDocsFromCompiler(); + symbolIndexer.schedule(); + } + + @Override + public void onFileModified(String filePath) { + symbolIndexer.schedule(); + } + + @Override + public void dispose() { + symbolIndexer.shutdown(); + } + + @Override + public void onCompileSucceeded() { + + populateDocsFromCompiler(); + // Also sync the indexer immediately so the debounced background pass + // reflects the compiled state right away. + symbolIndexer.indexNow(); + } + + /** + * Called on the EDT by the {@link SymbolIndexer} callback after each debounce cycle. + * Replaces all "Program" (user-defined) reference items with the freshly scanned symbols and + * refreshes the reference panel. + */ + private void updateProgramSymbols(List symbols) { + int fingerprint = 1; + for (IndexedSymbol symbol : symbols) { + fingerprint = 31 * fingerprint + Objects.hash(symbol.kind(), symbol.name(), symbol.signature()); + } + if (fingerprint == lastProgramSymbolsFingerprint) { + return; + } + lastProgramSymbolsFingerprint = fingerprint; + + // Remove all existing Program-sourced items + allReferenceItems.removeIf(item -> "Program".equals(item.library)); + + // Add newly scanned symbols + for (IndexedSymbol sym : symbols) { + String details; + String insertText; + int caretOffset; + switch (sym.kind()) { + case "userfunc" -> { + details = "" + + "

Type: User Function" + + "
Source: Program

" + + "

" + escapeHtml(sym.signature()) + "

" + + ""; + insertText = sym.name() + "()"; + caretOffset = sym.name().length() + 1; + } + case "label" -> { + details = "" + + "

Type: Label" + + "
Usage: gosub " + escapeHtml(sym.name()) + "" + + " / goto " + escapeHtml(sym.name()) + "

" + + ""; + insertText = sym.name(); + caretOffset = sym.name().length(); + } + case "struc" -> { + details = "" + + "

Type: Struct" + + "
Source: Program

" + + "

" + escapeHtml(sym.signature()) + "

" + + ""; + insertText = sym.name(); + caretOffset = sym.name().length(); + } + default -> { // "variable" + details = "" + + "

Type: Variable" + + "
Source: Program

" + + "

" + escapeHtml(sym.signature()) + "

" + + ""; + insertText = sym.name(); + caretOffset = sym.name().length(); + } + } + allReferenceItems.add(new ReferenceItem( + sym.kind(), sym.name(), sym.signature(), "Program", details, insertText, caretOffset)); + } + + allReferenceItems.sort(Comparator.comparing((ReferenceItem item) -> item.name, String.CASE_INSENSITIVE_ORDER) + .thenComparing(item -> item.kind)); + rebuildLibraryFilterOptions(); + filterReferenceItems(); + + // TODO handle this or not: refreshAssetsLibrary(); + } + + private void populateDocsFromCompiler() { + + if (context.currentEditor() == null || context.currentEditor().getLanguage() == null) { + return; + } + allReferenceItems.clear(); + allReferenceItems.addAll( + buildFunctionReferenceItems(context.currentEditor().getLanguage())); + allReferenceItems.addAll( + buildConstantReferenceItems(context.currentEditor().getLanguage())); + allReferenceItems.addAll( + buildLabelReferenceItems(context.currentEditor().getLanguage())); + allReferenceItems.addAll( + buildVariableReferenceItems(context.currentEditor().getLanguage())); + allReferenceItems.sort(Comparator.comparing((ReferenceItem item) -> item.name, String.CASE_INSENSITIVE_ORDER) + .thenComparing(item -> item.kind)); + rebuildLibraryFilterOptions(); + filterReferenceItems(); + } + + private java.util.List buildFunctionReferenceItems(LanguageService comp) { + java.util.List items = new ArrayList<>(); + for (FunctionDefinition item : comp.getFunctionDefinitions()) { + if (item == null) { + continue; + } + StringBuilder argsOnly = new StringBuilder(); + if (item.parameters() != null) { + for (VariableDefinition arg : item.parameters()) { + if (argsOnly.length() > 0) { + argsOnly.append(", "); + } + argsOnly.append(arg.signature()); + } + } + String details = "" + + "

Type: Function
Library: " + + escapeHtml(item.packageName()) + + "

" + + escapeHtml(item.signature()) + + "

"; + String insertText = item.hasBrackets() ? item.name() + "()" : item.name() + " "; + int caretOffset = item.hasBrackets() ? item.name().length() + 1 : insertText.length(); + if (item.hasBrackets() && argsOnly.length() > 0) { + insertText = item.name() + "(" + argsOnly + ")"; + caretOffset = item.name().length() + 1; + } + items.add(new ReferenceItem( + "function", item.name(), item.signature(), item.packageName(), details, insertText, caretOffset)); + } + return items; + } + + private java.util.List buildConstantReferenceItems(LanguageService comp) { + java.util.List items = new ArrayList<>(); + for (VariableDefinition item : comp.getConstantDefinitions()) { + if (item == null) { + continue; + } + String details = "" + + "

Type: Constant
Library: " + + escapeHtml(item.packageName()) + + "

" + + escapeHtml(item.signature()) + + "

"; + items.add(new ReferenceItem( + "constant", + item.name(), + item.signature(), + item.packageName(), + details, + item.name(), + item.name().length())); + } + + return items; + } + + private java.util.List buildLabelReferenceItems(LanguageService comp) { + java.util.List items = new ArrayList<>(); + for (LabelDefinition label : comp.getLabelDefinitions()) { + if (label == null) { + continue; + } + String signature = label.signature(); + String details = "" + + "

Type: Label
Usage: " + + "" + escapeHtml(label.usage()) + "" + + "

"; + items.add(new ReferenceItem( + "label", + label.name(), + signature, + "Program", + details, + label.name(), + label.name().length())); + } + return items; + } + + private java.util.List buildVariableReferenceItems(LanguageService comp) { + java.util.List items = new ArrayList<>(); + for (VariableDefinition variable : comp.getVariableDefinitions()) { + if (variable == null || variable.name() == null || variable.name().isEmpty()) { + continue; + } + String typeStr = variable.type().name(); + String signature = variable.signature(); + String details = "" + + "

Type: Variable
Data type: " + + escapeHtml(typeStr) + "
Source: Program

"; + items.add(new ReferenceItem( + "variable", + variable.name(), + signature, + "Program", + details, + variable.name(), + variable.name().length())); + } + return items; + } + + private void rebuildLibraryFilterOptions() { + String selected = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All Libraries"); + Set libraries = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (ReferenceItem item : allReferenceItems) { + if (item.library != null) { + libraries.add(item.library); + } + } + + updatingReferenceFilters = true; + try { + referenceLibraryFilter.removeAllItems(); + referenceLibraryFilter.addItem("All Libraries"); + for (String library : libraries) { + referenceLibraryFilter.addItem(library); + } + referenceLibraryFilter.setSelectedItem(libraries.contains(selected) ? selected : "All Libraries"); + } finally { + updatingReferenceFilters = false; + } + rebuildReferenceFiltersPopup(); + updateReferenceFiltersButtonTooltip(); + } + + private void rebuildReferenceFiltersPopup() { + referenceFiltersPopup.removeAll(); + + JMenu typeMenu = new JMenu("Type"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "All", "All"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Functions", "Functions"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Constants", "Constants"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Labels", "Labels"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Variables", "Variables"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Structs", "Structs"); + + JMenu sourceMenu = new JMenu("Source"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "All Sources", "All Sources"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Builtin", "Builtin"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Libraries", "Libraries"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Program", "Program"); + + JMenu libraryMenu = new JMenu("Library"); + for (int i = 0; i < referenceLibraryFilter.getItemCount(); i++) { + String item = referenceLibraryFilter.getItemAt(i); + if (item != null) { + addReferenceRadioItems(libraryMenu, referenceLibraryFilter, item, item); + } + } + + JMenuItem resetItem = new JMenuItem("Reset filters"); + resetItem.addActionListener(e -> resetReferenceFiltersAndSearch()); + + referenceFiltersPopup.add(typeMenu); + referenceFiltersPopup.add(sourceMenu); + referenceFiltersPopup.add(libraryMenu); + referenceFiltersPopup.addSeparator(); + referenceFiltersPopup.add(resetItem); + } + + private void addReferenceRadioItems(JMenu menu, JComboBox combo, String label, String value) { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(label, Objects.equals(combo.getSelectedItem(), value)); + item.addActionListener(e -> combo.setSelectedItem(value)); + menu.add(item); + } + + private void setReferenceDetailsHtml(String html) { + String next = html == null ? REFERENCE_SELECT_PROMPT_HTML : html; + if (Objects.equals(referenceDetailsHtml, next)) { + return; + } + referenceDetailsHtml = next; + referenceDetailsPane.setText(next); + referenceDetailsPane.setCaretPosition(0); + } + + private void updateReferenceFiltersButtonTooltip() { + String type = Objects.toString(referenceKindFilter.getSelectedItem(), "All"); + String source = Objects.toString(referenceSourceFilter.getSelectedItem(), "All Sources"); + String library = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All Libraries"); + referenceFiltersButton.setText("All".equals(type) ? "Code Reference" : type); + referenceFiltersButton.setToolTipText("Type: " + type + " | Source: " + source + " | Library: " + library); + } + + private void requestFilterReferenceItems() { + referenceFilterDebounceTimer.restart(); + } + + private void resetReferenceFiltersAndSearch() { + updatingReferenceFilters = true; + try { + referenceSearchField.setText(""); + referenceKindFilter.setSelectedItem("All"); + referenceSourceFilter.setSelectedItem("All Sources"); + referenceLibraryFilter.setSelectedItem("All Libraries"); + } finally { + updatingReferenceFilters = false; + } + updateReferenceFiltersButtonTooltip(); + filterReferenceItems(); + } + + private void filterReferenceItems() { + String query = referenceSearchField.getText(); + String needle = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); + String selectedKind = Objects.toString(referenceKindFilter.getSelectedItem(), "All"); + String selectedSource = Objects.toString(referenceSourceFilter.getSelectedItem(), "All Sources"); + String selectedLibrary = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); + + ReferenceItem previousSelection = referenceList.getSelectedValue(); + java.util.List matches = new ArrayList<>(); + for (ReferenceItem item : allReferenceItems) { + boolean kindMatches = "All".equals(selectedKind) + || ("Functions".equals(selectedKind) + && ("function".equals(item.kind) || "userfunc".equals(item.kind))) + || ("Constants".equals(selectedKind) && "constant".equals(item.kind)) + || ("Labels".equals(selectedKind) && "label".equals(item.kind)) + || ("Variables".equals(selectedKind) && "variable".equals(item.kind)) + || ("Structs".equals(selectedKind) && "struc".equals(item.kind)); + boolean sourceMatches = "All Sources".equals(selectedSource) + || ("Builtin".equals(selectedSource) + && item.library != null + && "Builtin".equalsIgnoreCase(item.library)) + || ("Libraries".equals(selectedSource) + && item.library != null + && !"Builtin".equalsIgnoreCase(item.library) + && !"Program".equalsIgnoreCase(item.library)) + || ("Program".equals(selectedSource) + && item.library != null + && "Program".equalsIgnoreCase(item.library)); + boolean libraryMatches = "All Libraries".equals(selectedLibrary) + || (item.library != null && selectedLibrary.equals(item.library)); + if (needle.isEmpty() + || item.name.toLowerCase(Locale.ROOT).contains(needle) + || item.signature.toLowerCase(Locale.ROOT).contains(needle) + || item.kind.toLowerCase(Locale.ROOT).contains(needle) + || (item.library != null + && item.library.toLowerCase(Locale.ROOT).contains(needle))) { + if (kindMatches && sourceMatches && libraryMatches) { + matches.add(item); + } + } + } + + rebuildingReferenceList = true; + try { + referenceListModel.clear(); + for (ReferenceItem match : matches) { + referenceListModel.addElement(match); + } + + if (!referenceListModel.isEmpty()) { + if (previousSelection != null && matches.contains(previousSelection)) { + referenceList.setSelectedValue(previousSelection, true); + } else { + referenceList.setSelectedIndex(0); + } + } + } finally { + rebuildingReferenceList = false; + } + + if (!referenceListModel.isEmpty()) { + showReferenceResultsList(true); + updateReferenceSelectionDetails(); + } else { + showReferenceResultsList(false); + setReferenceDetailsVisible(false); + setReferenceSelectionName("No selection"); + setReferenceDetailsHtml(REFERENCE_NO_MATCHES_HTML); + referenceCopyButton.setEnabled(false); + referenceInsertButton.setEnabled(false); + } + } + + private void showReferenceResultsList(boolean hasResults) { + CardLayout layout = (CardLayout) referenceResultsHost.getLayout(); + layout.show(referenceResultsHost, hasResults ? REFERENCE_RESULTS_LIST_CARD : REFERENCE_RESULTS_EMPTY_CARD); + } + + private void updateReferenceSelectionDetails() { + ReferenceItem item = referenceList.getSelectedValue(); + if (item == null) { + setReferenceDetailsVisible(false); + setReferenceSelectionName("Select an entry."); + setReferenceDetailsHtml(REFERENCE_SELECT_PROMPT_HTML); + referenceCopyButton.setEnabled(false); + referenceInsertButton.setEnabled(false); + return; + } + setReferenceDetailsVisible(true); + setReferenceSelectionName(item.name); + setReferenceDetailsHtml(item.details); + referenceCopyButton.setEnabled(true); + referenceInsertButton.setEnabled(true); + } + + private void setReferenceDetailsVisible(boolean visible) { + if (referenceSplitPane == null || referenceDetailsComponent == null) { + return; + } + + if (visible) { + if (referenceDetailsCollapsed + || referenceSplitPane.getBottomComponent() != referenceDetailsComponent) { + expandReferenceDetailsPane(); + } + } else if (!referenceDetailsCollapsed + || referenceSplitPane.getBottomComponent() == referenceDetailsComponent) { + collapseReferenceDetailsPane(); + } + } + + private void collapseReferenceDetailsPane() { + referenceDetailsCollapsed = true; + referenceSplitPane.setResizeWeight(1.0); + referenceSplitPane.setDividerSize(0); + referenceSplitPane.setBottomComponent(null); + referenceSplitPane.revalidate(); + referenceSplitPane.repaint(); + } + + private void expandReferenceDetailsPane() { + referenceDetailsCollapsed = false; + if (referenceSplitPane.getBottomComponent() != referenceDetailsComponent) { + referenceSplitPane.setBottomComponent(referenceDetailsComponent); + } + referenceSplitPane.setDividerSize(referenceDetailsDividerSize); + referenceSplitPane.setResizeWeight(RESULTS_SPLIT_WEIGHT); + referenceSplitPane.revalidate(); + + SwingUtilities.invokeLater(() -> { + if (referenceDetailsCollapsed + || referenceSplitPane.getBottomComponent() != referenceDetailsComponent) { + return; + } + + int splitPaneHeight = referenceSplitPane.getHeight(); + if (splitPaneHeight <= 0) { + return; + } + + int availableHeight = Math.max(0, splitPaneHeight - referenceSplitPane.getDividerSize()); + int minimumTopHeight = Math.min(80, availableHeight); + int maximumDetailsHeight = Math.max(0, availableHeight - minimumTopHeight); + int targetDetailsHeight = Math.max(140, + (int) Math.round(availableHeight * DETAILS_HEIGHT_RATIO)); + targetDetailsHeight = Math.min(targetDetailsHeight, maximumDetailsHeight); + + referenceSplitPane.setDividerLocation(availableHeight - targetDetailsHeight); + }); + } + + private void insertSelectedReference() { + ReferenceItem item = referenceList.getSelectedValue(); + if (item == null) { + return; + } + context.commands().insertText(item.insertText, item.caretOffset); + } + + private void copySelectedSymbolName() { + ReferenceItem item = referenceList.getSelectedValue(); + if (item == null || item.name == null || item.name.isBlank()) { + return; + } + StringSelection selection = new StringSelection(item.name); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + } + + private void setReferenceSelectionName(String name) { + referenceSelectionName = (name == null || name.isBlank()) ? "Select an entry." : name; + referenceSelectionNameLabel.setText(referenceSelectionName); + referenceSelectionNameLabel.setToolTipText(referenceSelectionName.isBlank() ? null : referenceSelectionName); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java b/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java new file mode 100644 index 00000000..035fcc92 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java @@ -0,0 +1,23 @@ +package com.basic4gl.desktop.util; + +import com.basic4gl.desktop.spi.DialogService; +import javax.swing.*; + +public class BasicDialogService implements DialogService { + private final JFrame frame; + + public BasicDialogService(JFrame frame) { + this.frame = frame; + } + + @Override + public void showDialog(String message) { + JOptionPane.showMessageDialog(frame, message); + } + + @Override + public String showInputDialog(String message, String title, String initialValue) { + return (String) + JOptionPane.showInputDialog(frame, message, title, JOptionPane.PLAIN_MESSAGE, null, null, initialValue); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/util/FileUtil.java b/app/src/main/java/com/basic4gl/desktop/util/FileUtil.java index cbf73966..3417c12e 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/FileUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/FileUtil.java @@ -1,5 +1,12 @@ package com.basic4gl.desktop.util; +import com.basic4gl.desktop.spi.DialogService; +import java.awt.*; +import java.io.File; +import java.io.IOException; +import java.util.Locale; +import javax.swing.*; + public class FileUtil { public static String fromUserHome(String absolutePath) { String userHome = System.getProperty("user.home"); @@ -9,4 +16,88 @@ public static String fromUserHome(String absolutePath) { } return absolutePath; // Return as-is if not in home directory } + + public static String getMediaTypeLabel(File file) { + if (file == null) { + return "Other"; + } + String name = file.getName().toLowerCase(Locale.ROOT); + if (name.endsWith(".png") + || name.endsWith(".jpg") + || name.endsWith(".jpeg") + || name.endsWith(".gif") + || name.endsWith(".bmp") + || name.endsWith(".webp") + || name.endsWith(".ico")) { + return "Images"; + } + if (name.endsWith(".wav") || name.endsWith(".ogg") || name.endsWith(".mp3") || name.endsWith(".flac")) { + return "Audio"; + } + if (name.endsWith(".mp4") || name.endsWith(".mov") || name.endsWith(".webm")) { + return "Video"; + } + if (name.endsWith(".txt") + || name.endsWith(".md") + || name.endsWith(".json") + || name.endsWith(".xml") + || name.endsWith(".csv") + || name.endsWith(".ini") + || name.endsWith(".cfg") + || name.endsWith(".properties")) { + return "Text"; + } + if (name.endsWith(".pdf") || name.endsWith(".doc") || name.endsWith(".docx") || name.endsWith(".rtf")) { + return "Documents"; + } + return "Other"; + } + + public static String formatRelativePath(File file, File baseDir) { + if (file == null) { + return ""; + } + if (baseDir != null) { + try { + java.nio.file.Path relative = baseDir.getAbsoluteFile() + .toPath() + .normalize() + .relativize(file.getAbsoluteFile().toPath().normalize()); + String text = relative.toString().replace('\\', '/'); + if (!text.startsWith("..")) { + return text; + } + } catch (Exception ignored) { + // Fall back to file name below. + } + } + return file.getName(); + } + + public static void revealInFinder(File file, DialogService dialogService) { + if (file == null || !file.exists()) { + return; + } + try { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browseFileDirectory(file); + } + } catch (Exception ex) { + // Fallback when browseFileDirectory is unavailable. + openWithSystemDefault(file.getParentFile(), dialogService); + } + } + + public static void openWithSystemDefault(File file, DialogService dialogService) { + if (file == null || !file.exists()) { + return; + } + try { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(file); + } + } catch (IOException ex) { + dialogService.showDialog("Unable to open file: " + ex.getMessage()); + } + } } diff --git a/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java b/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java new file mode 100644 index 00000000..329b3cbc --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java @@ -0,0 +1,26 @@ +package com.basic4gl.desktop.util; + +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; + +public final class HtmlUtil { + private static final Parser MARKDOWN_PARSER = Parser.builder().build(); + private static final HtmlRenderer MARKDOWN_RENDERER = HtmlRenderer.builder().build(); + + private HtmlUtil() {} + + public static String escapeHtml(String input) { + if (input == null) { + return ""; + } + return input.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + public static String markdownToHtml(String markdown) { + String input = markdown == null ? "" : markdown; + Node document = MARKDOWN_PARSER.parse(input); + String htmlBody = MARKDOWN_RENDERER.render(document); + return "" + htmlBody + ""; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/util/KeyStrokeUtil.java b/app/src/main/java/com/basic4gl/desktop/util/KeyStrokeUtil.java index c69b39ba..2db6fed6 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/KeyStrokeUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/KeyStrokeUtil.java @@ -1,6 +1,8 @@ package com.basic4gl.desktop.util; +import java.awt.*; import java.awt.event.KeyEvent; +import java.util.ArrayList; import javax.swing.*; public final class KeyStrokeUtil { @@ -13,13 +15,23 @@ public static String getShortcutString(KeyStroke keyStroke) { int modifiers = keyStroke.getModifiers(); int keyCode = keyStroke.getKeyCode(); - String modifierSymbol; - if (System.getProperty("os.name").toLowerCase().contains("mac")) { - modifierSymbol = "⌘"; // Command symbol for macOS - } else { - modifierSymbol = "Ctrl"; // Control for Windows/Linux + ArrayList modifierSymbol = new ArrayList<>(); + + Toolkit toolkit = Toolkit.getDefaultToolkit(); + + if ((modifiers & KeyEvent.SHIFT_DOWN_MASK) != 0) { + modifierSymbol.add("Shift"); + } + + if ((modifiers & toolkit.getMenuShortcutKeyMask()) != 0 + || (modifiers & toolkit.getMenuShortcutKeyMaskEx()) == 0) { + if (System.getProperty("os.name").toLowerCase().contains("mac")) { + modifierSymbol.add("⌘"); // Command symbol for macOS + } else { + modifierSymbol.add("Ctrl"); // Control for Windows/Linux + } } - return modifierSymbol + " " + KeyEvent.getKeyText(keyCode); + return String.join(" + ", modifierSymbol) + " " + KeyEvent.getKeyText(keyCode); } } diff --git a/app/src/main/java/com/basic4gl/desktop/util/RoundedCardPanel.java b/app/src/main/java/com/basic4gl/desktop/util/RoundedCardPanel.java new file mode 100644 index 00000000..bd9961cc --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/util/RoundedCardPanel.java @@ -0,0 +1,32 @@ +package com.basic4gl.desktop.util; + +import java.awt.*; +import javax.swing.*; + +public class RoundedCardPanel extends JPanel { + public static final int DEFAULT_ARC = 14; + + private final int arc; + + public RoundedCardPanel() { + this(DEFAULT_ARC); + } + + public RoundedCardPanel(int arc) { + this.arc = arc; + setOpaque(false); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(getBackground()); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), arc, arc); + } finally { + g2.dispose(); + } + super.paintComponent(g); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java b/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java index a2b231ba..eb8b11bc 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java @@ -1,5 +1,9 @@ package com.basic4gl.desktop.util; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; import javax.swing.*; public class SwingIconUtil { @@ -15,4 +19,70 @@ public static ImageIcon createImageIcon(String path) { return null; } } + + public static ImageIcon createImageIcon(String path, Color tint) { + ImageIcon baseIcon = createImageIcon(path); + if (baseIcon == null || tint == null) { + return baseIcon; + } + return new ImageIcon(tintImage(baseIcon.getImage(), tint)); + } + + public static Icon buildImageThumbnailIcon(File file, int maxWidth, int maxHeight) { + try { + java.awt.image.BufferedImage image = javax.imageio.ImageIO.read(file); + if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) { + return null; + } + double scale = Math.min((double) maxWidth / image.getWidth(), (double) maxHeight / image.getHeight()); + scale = Math.min(1.0d, scale); + int width = Math.max(1, (int) Math.round(image.getWidth() * scale)); + int height = Math.max(1, (int) Math.round(image.getHeight() * scale)); + Image scaled = image.getScaledInstance(width, height, Image.SCALE_SMOOTH); + return new ImageIcon(scaled); + } catch (IOException ex) { + return null; + } + } + + public static Icon createScaledIcon(String iconPath, int size) { + ImageIcon icon = createImageIcon(iconPath); + if (icon == null) { + return null; + } + Image scaled = icon.getImage().getScaledInstance(size, size, Image.SCALE_SMOOTH); + return new ImageIcon(scaled); + } + + public static Icon createScaledIcon(String iconPath, int size, Color tint) { + ImageIcon icon = createImageIcon(iconPath, tint); + if (icon == null) { + return null; + } + Image scaled = icon.getImage().getScaledInstance(size, size, Image.SCALE_SMOOTH); + return new ImageIcon(scaled); + } + + private static BufferedImage tintImage(Image source, Color tint) { + int width = source.getWidth(null); + int height = source.getHeight(null); + BufferedImage sourceImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D sourceGraphics = sourceImage.createGraphics(); + sourceGraphics.drawImage(source, 0, 0, null); + sourceGraphics.dispose(); + + BufferedImage tinted = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + int tintRgb = tint.getRGB() & 0x00FFFFFF; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int argb = sourceImage.getRGB(x, y); + int alpha = (argb >>> 24) & 0xFF; + if (alpha == 0) { + continue; + } + tinted.setRGB(x, y, (alpha << 24) | tintRgb); + } + } + return tinted; + } } diff --git a/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java b/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java new file mode 100644 index 00000000..d8ef0c87 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java @@ -0,0 +1,36 @@ +package com.basic4gl.desktop.util; + +import com.formdev.flatlaf.FlatClientProperties; +import java.awt.*; +import javax.swing.*; + +public class SwingUtil { + + public static void configureSmoothScrolling(JScrollPane scrollPane) { + scrollPane.getVerticalScrollBar().setUnitIncrement(16); + scrollPane.getVerticalScrollBar().setBlockIncrement(64); + scrollPane.getViewport().setScrollMode(JViewport.BLIT_SCROLL_MODE); + scrollPane.setWheelScrollingEnabled(true); + } + + public static void hideSplitPaneHandle(JSplitPane splitPane) { + if (splitPane == null) { + return; + } + + splitPane.putClientProperty(FlatClientProperties.STYLE, "style: plain"); + + splitPane.setOneTouchExpandable(false); + } + + public static Color createLighterPanelBackground() { + Color base = UIManager.getColor("Panel.background"); + if (base == null) { + base = new Color(238, 238, 238); + } + return new Color( + Math.min(255, base.getRed() + 8), + Math.min(255, base.getGreen() + 8), + Math.min(255, base.getBlue() + 8)); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/vmview/VirtualMachineViewDialog.java b/app/src/main/java/com/basic4gl/desktop/vmview/VirtualMachineViewDialog.java index b75fcea6..d8d1392b 100644 --- a/app/src/main/java/com/basic4gl/desktop/vmview/VirtualMachineViewDialog.java +++ b/app/src/main/java/com/basic4gl/desktop/vmview/VirtualMachineViewDialog.java @@ -2,6 +2,7 @@ import static com.basic4gl.desktop.Theme.*; import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; @@ -303,6 +304,7 @@ private JPanel createVariablesGroup() { JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, tableScrollPane, detailPanel); splitPane.setResizeWeight(0.7); splitPane.setOneTouchExpandable(true); + hideSplitPaneHandle(splitPane); panel.add(splitPane, BorderLayout.CENTER); panel.add(variablesStatusLabel, BorderLayout.SOUTH); diff --git a/app/src/test/java/com/basic4gl/desktop/content/ContentDocumentViewerTest.java b/app/src/test/java/com/basic4gl/desktop/content/ContentDocumentViewerTest.java new file mode 100644 index 00000000..fc8558c3 --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/ContentDocumentViewerTest.java @@ -0,0 +1,51 @@ +package com.basic4gl.desktop.content; + +import static org.junit.Assert.*; + +import com.basic4gl.desktop.editor.IFileViewer; +import com.basic4gl.desktop.spi.content.ContentDocument; +import com.basic4gl.desktop.spi.content.MapContentSource; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ContentDocumentViewerTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void documentationViewerTracksNavigationHistoryAndPinnedState() throws Exception { + MapContentSource source = MapContentSource.fromBytes(Map.of( + "index.md", "# Home".getBytes(StandardCharsets.UTF_8), + "page2.txt", "Page 2".getBytes(StandardCharsets.UTF_8))); + Path root = new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()) + .materialize("plugin", "docs", "1", source); + + ContentDocumentViewer viewer = new ContentDocumentViewer( + "plugin:docs:index", "Docs", new ContentDocument("text/markdown", source, "index.md"), root); + + assertEquals(IFileViewer.ViewerType.DOCUMENTATION_VIEWER, viewer.getViewerType()); + assertEquals("index.md", viewer.getCurrentPath()); + assertFalse(viewer.isModified()); + assertTrue(viewer.getFilePath().startsWith("content:plugin:docs:index#index.md")); + + viewer.navigateTo("page2.txt"); + assertEquals("page2.txt", viewer.getCurrentPath()); + + viewer.goBack(); + assertEquals("index.md", viewer.getCurrentPath()); + + viewer.goForward(); + assertEquals("page2.txt", viewer.getCurrentPath()); + + viewer.goHome(); + assertEquals("index.md", viewer.getCurrentPath()); + + viewer.keepOpen(); + assertTrue(viewer.isPinned()); + } +} diff --git a/app/src/test/java/com/basic4gl/desktop/content/ContentMaterializerTest.java b/app/src/test/java/com/basic4gl/desktop/content/ContentMaterializerTest.java new file mode 100644 index 00000000..5af40c75 --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/ContentMaterializerTest.java @@ -0,0 +1,94 @@ +package com.basic4gl.desktop.content; + +import static org.junit.Assert.*; + +import com.basic4gl.desktop.spi.content.ContentResource; +import com.basic4gl.desktop.spi.content.ContentSource; +import com.basic4gl.desktop.spi.content.DirectoryContentSource; +import com.basic4gl.desktop.spi.content.MapContentSource; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ContentMaterializerTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void materializesMapContentAndReusesSameVersion() throws Exception { + ContentMaterializer materializer = + new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()); + MapContentSource source = MapContentSource.fromBytes(Map.of( + "index.md", "Index".getBytes(StandardCharsets.UTF_8), + "images/sprite.png", "Image".getBytes(StandardCharsets.UTF_8))); + + Path first = materializer.materialize("plugin id", "docs", "1.0", source); + Path second = materializer.materialize("plugin id", "docs", "1.0", source); + + assertEquals(first, second); + assertEquals("Index", Files.readString(first.resolve("index.md"))); + assertEquals("Image", Files.readString(first.resolve("images/sprite.png"))); + assertTrue(first.toString().contains("plugin_id")); + } + + @Test + public void providerVersionChangesMaterializedDirectory() throws Exception { + ContentMaterializer materializer = + new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()); + MapContentSource source = + MapContentSource.fromBytes(Map.of("index.md", "Index".getBytes(StandardCharsets.UTF_8))); + + Path version1 = materializer.materialize("plugin", "docs", "1", source); + Path version2 = materializer.materialize("plugin", "docs", "2", source); + + assertNotEquals(version1, version2); + + materializer.deleteObsoleteVersions("plugin", "docs", "2"); + + assertFalse(Files.exists(version1)); + assertTrue(Files.exists(version2)); + } + + @Test + public void materializesDirectorySource() throws Exception { + Path root = temporaryFolder.newFolder("docs").toPath(); + Files.createDirectories(root.resolve("tutorials")); + Files.writeString(root.resolve("tutorials/sprites.md"), "Sprites"); + + ContentMaterializer materializer = + new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()); + Path materialized = materializer.materialize("plugin", "docs", "1", new DirectoryContentSource(root)); + + assertEquals("Sprites", Files.readString(materialized.resolve("tutorials/sprites.md"))); + } + + @Test + public void rejectsUnsafeResourcePaths() throws Exception { + ContentMaterializer materializer = + new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()); + + assertThrows(IOException.class, () -> materializer.materialize("plugin", "docs", "1", new UnsafeSource())); + } + + private static final class UnsafeSource implements ContentSource { + @Override + public InputStream open(String normalizedPath) { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public Collection resources() { + return List.of(new ContentResource("../../outside.md")); + } + } +} diff --git a/app/src/test/java/com/basic4gl/desktop/content/FileEditorDefaultNameTest.java b/app/src/test/java/com/basic4gl/desktop/content/FileEditorDefaultNameTest.java new file mode 100644 index 00000000..fb3c3a9f --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/FileEditorDefaultNameTest.java @@ -0,0 +1,28 @@ +package com.basic4gl.desktop.content; + +import static org.junit.Assert.assertEquals; + +import java.util.List; +import org.junit.Test; + +public class FileEditorDefaultNameTest { + + @Test + public void firstDefaultNameDoesNotUseNumericSuffix() { + assertEquals("Untitled", FileEditor.getNextDefaultName(List.of())); + } + + @Test + public void defaultNamesUseLowestAvailableIndex() { + assertEquals("Untitled 2", FileEditor.getNextDefaultName(List.of("Untitled"))); + assertEquals("Untitled 2", FileEditor.getNextDefaultName(List.of("Untitled", "Untitled 3"))); + assertEquals("Untitled 3", FileEditor.getNextDefaultName(List.of("Untitled", "Untitled 2"))); + } + + @Test + public void defaultNameMatchingIgnoresNonDefaultNames() { + assertEquals( + "Untitled 2", + FileEditor.getNextDefaultName(List.of("Untitled", "Untitled 1", "Untitled-two", "program.gb"))); + } +} diff --git a/app/src/test/java/com/basic4gl/desktop/content/TemplateInstantiatorTest.java b/app/src/test/java/com/basic4gl/desktop/content/TemplateInstantiatorTest.java new file mode 100644 index 00000000..34155e1f --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/TemplateInstantiatorTest.java @@ -0,0 +1,90 @@ +package com.basic4gl.desktop.content; + +import static org.junit.Assert.*; + +import com.basic4gl.desktop.content.catalog.ContentGlobalId; +import com.basic4gl.desktop.content.catalog.TemplateCatalogEntry; +import com.basic4gl.desktop.spi.content.TemplateCreationRequest; +import com.basic4gl.desktop.spi.content.TemplateDescriptor; +import com.basic4gl.desktop.spi.content.TemplateProvider; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class TemplateInstantiatorTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void instantiatesGeneratedTemplateAndReturnsEntryPoint() throws Exception { + GeneratedTemplateProvider provider = new GeneratedTemplateProvider(); + TemplateCatalogEntry entry = new TemplateCatalogEntry( + new ContentGlobalId("plugin", "templates", "sample"), + "Plugin", + "1", + new TemplateDescriptor( + "sample", "Sample", "", List.of("Graphics"), Set.of("sample"), 0, "src/main.gb", List.of()), + provider); + Path destination = temporaryFolder.getRoot().toPath().resolve("SpriteDemo"); + + Optional entryPoint = + new TemplateInstantiator().instantiate(entry, destination, "Sprite Demo", Map.of("message", "Hello")); + + assertTrue(entryPoint.isPresent()); + assertEquals(destination.resolve("src/main.gb").toAbsolutePath().normalize(), entryPoint.get()); + assertEquals("Hello", Files.readString(entryPoint.get())); + } + + @Test + public void refusesExistingDestinationBeforeCallingProvider() throws Exception { + GeneratedTemplateProvider provider = new GeneratedTemplateProvider(); + Path destination = temporaryFolder.newFolder("Existing").toPath(); + TemplateCatalogEntry entry = new TemplateCatalogEntry( + new ContentGlobalId("plugin", "templates", "sample"), + "Plugin", + "1", + new TemplateDescriptor("sample", "Sample", "", List.of(), Set.of("sample"), 0, "main.gb", List.of()), + provider); + + assertThrows(FileAlreadyExistsException.class, () -> new TemplateInstantiator() + .instantiate(entry, destination, "", Map.of())); + assertFalse(provider.called); + } + + private static final class GeneratedTemplateProvider implements TemplateProvider { + private boolean called; + + @Override + public String id() { + return "templates"; + } + + @Override + public String version() { + return "1"; + } + + @Override + public Collection getIndex() { + return List.of(); + } + + @Override + public void instantiate(String templateId, TemplateCreationRequest request) throws IOException { + called = true; + Path entryPoint = request.destination().resolve("src/main.gb"); + Files.createDirectories(entryPoint.getParent()); + Files.writeString(entryPoint, request.variables().getOrDefault("message", "")); + } + } +} diff --git a/app/src/test/java/com/basic4gl/desktop/content/catalog/ContentCatalogTest.java b/app/src/test/java/com/basic4gl/desktop/content/catalog/ContentCatalogTest.java new file mode 100644 index 00000000..e384dc01 --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/catalog/ContentCatalogTest.java @@ -0,0 +1,187 @@ +package com.basic4gl.desktop.content.catalog; + +import static org.junit.Assert.*; + +import com.basic4gl.desktop.spi.content.ContentRegistration; +import com.basic4gl.desktop.spi.content.DocumentDescriptor; +import com.basic4gl.desktop.spi.content.DocumentProvider; +import com.basic4gl.desktop.spi.content.TemplateCreationRequest; +import com.basic4gl.desktop.spi.content.TemplateDescriptor; +import com.basic4gl.desktop.spi.content.TemplateProvider; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +public class ContentCatalogTest { + + @Test + public void registersAndRemovesDocumentProviders() { + ContentCatalog catalog = new ContentCatalog(); + AtomicInteger changes = new AtomicInteger(); + catalog.addListener(changes::incrementAndGet); + + ContentRegistration registration = catalog.registerDocumentProvider( + "plugin", "Plugin", new TestDocumentProvider("docs", List.of(document("sprites", "Sprites")))); + + assertEquals(1, catalog.documents().size()); + assertEquals( + "plugin:docs:sprites", catalog.documents().get(0).globalId().value()); + assertEquals(1, changes.get()); + + registration.close(); + registration.close(); + + assertTrue(catalog.documents().isEmpty()); + assertEquals(2, changes.get()); + } + + @Test + public void rejectsDuplicateProviderIdsForSamePluginAcrossProviderTypes() { + ContentCatalog catalog = new ContentCatalog(); + catalog.registerDocumentProvider( + "plugin", "Plugin", new TestDocumentProvider("content", List.of(document("doc", "Doc")))); + + assertThrows( + IllegalArgumentException.class, + () -> catalog.registerTemplateProvider( + "plugin", + "Plugin", + new TestTemplateProvider("content", List.of(template("sample", "Sample"))))); + } + + @Test + public void duplicateItemIdsRejectProviderAtomically() { + ContentCatalog catalog = new ContentCatalog(); + + assertThrows( + IllegalArgumentException.class, + () -> catalog.registerDocumentProvider( + "plugin", + "Plugin", + new TestDocumentProvider("docs", List.of(document("same", "One"), document("same", "Two"))))); + + assertTrue(catalog.documents().isEmpty()); + } + + @Test + public void missingRelationshipsDoNotRejectProvider() { + ContentCatalog catalog = new ContentCatalog(); + + catalog.registerDocumentProvider( + "plugin", + "Plugin", + new TestDocumentProvider( + "docs", + List.of(new DocumentDescriptor( + "sprites", + "Sprites", + "", + List.of("Graphics"), + Set.of("learn"), + 0, + List.of("missing-template"))))); + + assertEquals(1, catalog.documents().size()); + } + + @Test + public void pluginUnloadRemovesOwnedProviders() { + ContentCatalog catalog = new ContentCatalog(); + catalog.registerDocumentProvider( + "plugin1", "Plugin 1", new TestDocumentProvider("docs", List.of(document("doc", "Doc")))); + catalog.registerTemplateProvider( + "plugin1", "Plugin 1", new TestTemplateProvider("samples", List.of(template("sample", "Sample")))); + catalog.registerDocumentProvider( + "plugin2", "Plugin 2", new TestDocumentProvider("docs", List.of(document("other", "Other")))); + + catalog.unregisterPlugin("plugin1"); + + assertEquals(1, catalog.documents().size()); + assertEquals("plugin2:docs:other", catalog.documents().get(0).globalId().value()); + assertTrue(catalog.templates().isEmpty()); + } + + @Test + public void searchIndexesDocumentsAndTemplatesByMetadata() { + ContentCatalog catalog = new ContentCatalog(); + catalog.registerDocumentProvider( + "plugin", + "Plugin", + new TestDocumentProvider( + "docs", + List.of(new DocumentDescriptor( + "sprites", + "Drawing Sprites", + "Graphics tutorial", + List.of("Graphics"), + Set.of("learn"), + 0, + List.of())))); + catalog.registerTemplateProvider( + "plugin2", + "Samples Plugin", + new TestTemplateProvider( + "samples", + List.of(new TemplateDescriptor( + "sprite-demo", + "Sprite Demo", + "Animation sample", + List.of("Graphics"), + Set.of("sample"), + 0, + "main.gb", + List.of())))); + + List results = + new ContentSearchIndex().search(catalog.documents(), catalog.templates(), "sprite"); + + assertEquals(2, results.size()); + assertEquals("Sprite Demo", results.get(0).content().title()); + assertEquals("Drawing Sprites", results.get(1).content().title()); + } + + private static DocumentDescriptor document(String id, String title) { + return new DocumentDescriptor(id, title, "", List.of(), Set.of(), 0, List.of()); + } + + private static TemplateDescriptor template(String id, String title) { + return new TemplateDescriptor(id, title, "", List.of(), Set.of("sample"), 0, "main.gb", List.of()); + } + + private record TestDocumentProvider(String id, Collection descriptors) + implements DocumentProvider { + @Override + public String version() { + return "1"; + } + + @Override + public Collection getIndex() { + return descriptors; + } + + @Override + public com.basic4gl.desktop.spi.content.ContentDocument openDocument(String documentId) throws IOException { + throw new IOException("Not used"); + } + } + + private record TestTemplateProvider(String id, Collection descriptors) + implements TemplateProvider { + @Override + public String version() { + return "1"; + } + + @Override + public Collection getIndex() { + return descriptors; + } + + @Override + public void instantiate(String templateId, TemplateCreationRequest request) {} + } +} diff --git a/app/src/test/java/com/basic4gl/desktop/content/catalog/ContentPanelModelTest.java b/app/src/test/java/com/basic4gl/desktop/content/catalog/ContentPanelModelTest.java new file mode 100644 index 00000000..4587d6bd --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/catalog/ContentPanelModelTest.java @@ -0,0 +1,165 @@ +package com.basic4gl.desktop.content.catalog; + +import static org.junit.Assert.*; + +import com.basic4gl.desktop.spi.content.DocumentDescriptor; +import com.basic4gl.desktop.spi.content.DocumentProvider; +import com.basic4gl.desktop.spi.content.TemplateCreationRequest; +import com.basic4gl.desktop.spi.content.TemplateDescriptor; +import com.basic4gl.desktop.spi.content.TemplateProvider; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import org.junit.Test; + +public class ContentPanelModelTest { + + @Test + public void scopesAreDerivedFromAvailableTags() { + ContentCatalog catalog = catalog(); + ContentPanelModel model = new ContentPanelModel(); + + assertEquals( + List.of( + "All Documentation", + "Learn", + "Reference", + "Sample", + "Starter", + "Tutorial"), + model.scopes(catalog).stream().map(ContentScope::displayName).toList()); + assertEquals(List.of("Drawing Sprites"), titles(model.items(catalog, ContentScope.forTag("learn"), ""))); + assertEquals(List.of("Library Reference"), titles(model.items(catalog, ContentScope.forTag("reference"), ""))); + assertEquals(List.of("Sprite Demo"), titles(model.items(catalog, ContentScope.forTag("sample"), ""))); + assertEquals( + List.of("Blank Program", "Drawing Sprites", "Library Reference", "Sprite Demo"), + titles(model.items(catalog, ContentScope.ALL, ""))); + } + + @Test + public void browseBuildsCategoryHierarchy() { + ContentBrowseNode root = new ContentPanelModel().browse(catalog(), ContentScope.forTag("tutorial")); + + assertEquals("Tutorial", root.name()); + assertEquals(1, root.children().size()); + ContentBrowseNode graphics = root.children().get(0); + assertEquals("Graphics", graphics.name()); + assertEquals(List.of("Drawing Sprites"), titles(graphics.items())); + } + + @Test + public void searchReturnsFlatRankedResultsWithinScope() { + List results = new ContentPanelModel().items(catalog(), ContentScope.ALL, "sprite"); + + assertEquals(List.of("Sprite Demo", "Drawing Sprites"), titles(results)); + } + + @Test + public void summaryUsesTagDrivenLabelsAndActions() { + ContentPanelModel model = new ContentPanelModel(); + ContentPanelItem sample = + model.items(catalog(), ContentScope.forTag("sample"), "").get(0); + + ContentSelectionSummary summary = model.summary(sample); + + assertEquals("Sprite Demo", summary.title()); + assertEquals("Sample", summary.kindLabel()); + assertEquals("Open Sample", summary.primaryAction()); + assertEquals("Graphics", summary.category()); + assertEquals(List.of("drawing-sprites"), summary.relatedIds()); + } + + private static ContentCatalog catalog() { + ContentCatalog catalog = new ContentCatalog(); + catalog.registerDocumentProvider( + "plugin", + "Plugin", + new Docs(List.of( + new DocumentDescriptor( + "drawing-sprites", + "Drawing Sprites", + "Learn graphics", + List.of("Graphics"), + Set.of("learn", "tutorial"), + 0, + List.of("sprite-demo")), + new DocumentDescriptor( + "reference", + "Library Reference", + "API docs", + List.of("Reference"), + Set.of("reference"), + 0, + List.of())))); + catalog.registerTemplateProvider( + "plugin", + "Plugin", + new Templates(List.of( + new TemplateDescriptor( + "blank", + "Blank Program", + "Starter", + List.of("Basics"), + Set.of("starter"), + 0, + "main.gb", + List.of()), + new TemplateDescriptor( + "sprite-demo", + "Sprite Demo", + "Sample", + List.of("Graphics"), + Set.of("sample"), + 1, + "main.gb", + List.of("drawing-sprites"))))); + return catalog; + } + + private static List titles(List items) { + return items.stream().map(ContentPanelItem::title).toList(); + } + + private record Docs(Collection descriptors) implements DocumentProvider { + @Override + public String id() { + return "docs"; + } + + @Override + public String version() { + return "1"; + } + + @Override + public Collection getIndex() { + return descriptors; + } + + @Override + public com.basic4gl.desktop.spi.content.ContentDocument openDocument(String documentId) throws IOException { + throw new IOException("Not used"); + } + } + + private record Templates(Collection descriptors) implements TemplateProvider { + @Override + public String id() { + return "templates"; + } + + @Override + public String version() { + return "1"; + } + + @Override + public Collection getIndex() { + return descriptors; + } + + @Override + public void instantiate(String templateId, TemplateCreationRequest request) {} + } +} diff --git a/app/src/test/java/com/basic4gl/desktop/content/render/ContentRendererTest.java b/app/src/test/java/com/basic4gl/desktop/content/render/ContentRendererTest.java new file mode 100644 index 00000000..8faaa537 --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/render/ContentRendererTest.java @@ -0,0 +1,133 @@ +package com.basic4gl.desktop.content.render; + +import static org.junit.Assert.*; + +import com.basic4gl.desktop.content.ContentMaterializer; +import com.basic4gl.desktop.spi.content.ContentDocument; +import com.basic4gl.desktop.spi.content.MapContentSource; +import java.awt.Component; +import java.awt.Container; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Map; +import javax.imageio.ImageIO; +import javax.swing.JComponent; +import javax.swing.JEditorPane; +import javax.swing.JTextArea; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ContentRendererTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void markdownRendererRewritesImagesAndInternalLinks() throws Exception { + String markdown = "# Drawing Sprites\n" + + "![Sprite](../images/sprite.png)\n" + + "[Next](page2.md)\n" + + "![Remote](https://example.invalid/remote.png)\n" + + ""; + MapContentSource source = MapContentSource.fromBytes(Map.of( + "tutorials/sprites.md", markdown.getBytes(StandardCharsets.UTF_8), + "tutorials/page2.md", "Next".getBytes(StandardCharsets.UTF_8), + "images/sprite.png", pngBytes())); + Path root = new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()) + .materialize("plugin", "docs", "1", source); + + JComponent component = new MarkdownContentRenderer() + .render(new ContentRenderRequest( + new ContentDocument("text/markdown", source, "tutorials/sprites.md"), + root, + "tutorials/sprites.md", + ContentNavigationHandler.NO_OP)); + + String html = html(component); + assertTrue(html.contains(root.resolve("images/sprite.png").toUri().toString())); + assertTrue(html.contains("content:tutorials/page2.md")); + assertFalse(html.contains("https://example.invalid/remote.png")); + assertTrue(html.contains("<script>alert('x')</script>")); + } + + @Test + public void htmlRendererDisplaysHtmlContent() throws Exception { + MapContentSource source = + MapContentSource.fromBytes(Map.of("index.html", "

Hello

".getBytes(StandardCharsets.UTF_8))); + Path root = new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()) + .materialize("plugin", "docs", "1", source); + + JComponent component = new HtmlContentRenderer() + .render(new ContentRenderRequest( + new ContentDocument("text/html", source, "index.html"), + root, + "index.html", + ContentNavigationHandler.NO_OP)); + + assertTrue(html(component).contains("

Hello

")); + } + + @Test + public void plainTextRendererDisplaysReadOnlyText() throws Exception { + MapContentSource source = + MapContentSource.fromBytes(Map.of("readme.txt", "Hello".getBytes(StandardCharsets.UTF_8))); + Path root = new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()) + .materialize("plugin", "docs", "1", source); + + JComponent component = new PlainTextContentRenderer() + .render(new ContentRenderRequest( + new ContentDocument("text/plain", source, "readme.txt"), + root, + "readme.txt", + ContentNavigationHandler.NO_OP)); + + JTextArea textArea = find(component, JTextArea.class); + assertEquals("Hello", textArea.getText()); + assertFalse(textArea.isEditable()); + } + + @Test + public void unsupportedRendererDoesNotCrash() { + MapContentSource source = MapContentSource.fromBytes(Map.of("data.bin", new byte[0])); + + JComponent component = new UnsupportedContentRenderer() + .render(new ContentRenderRequest( + new ContentDocument("application/octet-stream", source, "data.bin"), + Path.of("."), + "data.bin", + ContentNavigationHandler.NO_OP)); + + assertTrue(html(component).contains("Unsupported content")); + } + + private static String html(JComponent component) { + JEditorPane pane = find(component, JEditorPane.class); + Object html = pane.getClientProperty("basic4gl.content.html"); + return html == null ? pane.getText() : html.toString(); + } + + private static byte[] pngBytes() throws Exception { + BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + ImageIO.write(image, "png", output); + return output.toByteArray(); + } + + private static T find(Component component, Class type) { + if (type.isInstance(component)) { + return type.cast(component); + } + if (component instanceof Container container) { + for (Component child : container.getComponents()) { + T found = find(child, type); + if (found != null) { + return found; + } + } + } + return null; + } +} diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java index 2358d6b7..34ee6e67 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java @@ -5,6 +5,9 @@ import com.basic4gl.compiler.Preprocessor; import com.basic4gl.compiler.TomBasicCompiler; import com.basic4gl.desktop.spi.*; +import com.basic4gl.desktop.spi.content.ClasspathContentSource; +import com.basic4gl.desktop.spi.content.DirectoryDocumentProvider; +import com.basic4gl.desktop.spi.content.DirectoryTemplateProvider; import com.basic4gl.desktop.spi.language.LanguageSupport; import com.basic4gl.language.adapter.menu.ReferenceWindow; import com.basic4gl.library.plugin.PluginJAR; @@ -13,6 +16,7 @@ import com.basic4gl.library.plugin.PluginJARManager; import com.basic4gl.runtime.Debugger; import com.basic4gl.runtime.TomVM; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashSet; @@ -158,11 +162,38 @@ public void onLoad(PluginContext context) { window.populate(compiler); window.setVisible(true); }); + registerBuiltInContent(context); plugins.setCurrentDirectory(getDefaultPluginDirectory()); applyPluginSettingsToManager(); attemptLoadPluginsFromCurrentDirectory(); } + private void registerBuiltInContent(PluginContext context) { + ClassLoader classLoader = getClass().getClassLoader(); + try { + context.content() + .registerDocumentProvider(new DirectoryDocumentProvider( + "basic4gl-docs", + getVersion(), + new ClasspathContentSource( + classLoader, + "basic4gl-content/docs/basic4gl", + "basic4gl-content/docs/basic4gl.index"), + List.of("Basic4GL"))); + context.content() + .registerTemplateProvider(new DirectoryTemplateProvider( + "basic4gl-samples", + getVersion(), + new ClasspathContentSource( + classLoader, + "basic4gl-content/samples/Programs", + "basic4gl-content/samples/Programs.index"), + List.of("Samples"))); + } catch (IOException | RuntimeException ex) { + System.err.println("Unable to register built-in Basic4GL content: " + ex.getMessage()); + } + } + @Override public Configuration getAppSettings() { return ConfigurationMapper.toEditorConfiguration(appSettings); diff --git a/language-adapter/src/test/java/com/basic4gl/language/adapter/Basic4GLContentResourcesTest.java b/language-adapter/src/test/java/com/basic4gl/language/adapter/Basic4GLContentResourcesTest.java new file mode 100644 index 00000000..3171258c --- /dev/null +++ b/language-adapter/src/test/java/com/basic4gl/language/adapter/Basic4GLContentResourcesTest.java @@ -0,0 +1,25 @@ +package com.basic4gl.language.adapter; + +import static org.junit.jupiter.api.Assertions.*; + +import com.basic4gl.desktop.spi.content.ClasspathContentSource; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class Basic4GLContentResourcesTest { + + @Test + void embedsDocsAndSamplesWithIndexes() throws Exception { + ClassLoader classLoader = getClass().getClassLoader(); + ClasspathContentSource docs = new ClasspathContentSource( + classLoader, "basic4gl-content/docs/basic4gl", "basic4gl-content/docs/basic4gl.index"); + ClasspathContentSource samples = new ClasspathContentSource( + classLoader, "basic4gl-content/samples/Programs", "basic4gl-content/samples/Programs.index"); + + assertTrue( + docs.resources().stream().anyMatch(resource -> resource.path().equals("index.md"))); + assertTrue(samples.resources().stream() + .anyMatch(resource -> resource.path().equals("AsteroidDemo.gb"))); + assertTrue(new String(docs.open("index.md").readAllBytes(), StandardCharsets.UTF_8).contains("Basic4GL")); + } +}