Skip to content

Commit 73ef5ba

Browse files
committed
Improve editor feedback and project file workflows
Add bracket-aware indentation, stable mouse selections, and diagnostic markers that follow edits. Cache semantic completion state and project asset paths between requests. Import dropped images and sounds without overwriting existing files. Preview file moves and renames with selectable reference updates, keep dialogs owned by the IDE window, and scan usages in background workers. Show progress while opening projects and starting programs, guard rapid run/stop requests, and stop sound playback when its tab closes. Add regression coverage and include file imports and actions in UI smoke checks. Validation: full Maven verification passed (459 unit tests), followed by 30 UI integration and smoke tests under Xvfb. No failures or skipped tests.
1 parent 6f362cf commit 73ef5ba

36 files changed

Lines changed: 2450 additions & 328 deletions

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ project format may change while we test it with students and teachers.
2121
stage or sprite editor. The switch at the end of the tab strip shows the
2222
code, both side by side, or the visual editor alone.
2323
- Edit images, sounds, sprite sheets, hitboxes, shaders, and Tiled maps.
24+
- Drag images (PNG, JPG, GIF) and sounds (WAV, OGG, MP3, AIFF, AU) from your
25+
file manager onto the Files pane to copy them into the project. Existing
26+
files keep their contents; matching names get a number.
2427
- Run programs in a separate JVM, use the console and debugger, and export a
2528
runnable JAR, a portable app folder, or a BlueJ/VS Code project ZIP.
2629

‎core/src/main/java/org/openpatch/scratch4j/core/assets/AssetCopier.java‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,47 @@
88
import java.nio.file.Files;
99
import java.nio.file.Path;
1010
import java.nio.file.StandardCopyOption;
11+
import java.util.Locale;
1112

1213
/**
1314
* "Copy built-in costume/sound into the project to edit": crops a built-in
1415
* image out of its Kenney atlas (pre-rotated to face right, as the library's
1516
* loader does) into {@code assets/images}, and copies a built-in sound file
16-
* into {@code assets/sounds}. The project then owns an editable file.
17+
* into {@code assets/sounds}. Also imports image and sound files from the
18+
* system file manager. The project then owns an editable file.
1719
*/
1820
public final class AssetCopier {
1921

2022
private AssetCopier() {}
2123

24+
/** Supported external assets, checked without reading the disk during a drag. */
25+
public static boolean canImport(Path source) {
26+
return importFolder(source) != null;
27+
}
28+
29+
/** Copies an external asset into its project folder, keeping the original and any namesakes. */
30+
public static Path copyExternalFile(Path source, Path projectRoot) throws IOException {
31+
String folder = importFolder(source);
32+
if (folder == null || !Files.isRegularFile(source)) {
33+
throw new IOException("Choose an image or sound file: " + source.getFileName());
34+
}
35+
Path target = uniqueTarget(projectRoot.resolve("assets").resolve(folder),
36+
source.getFileName().toString());
37+
return Files.copy(source, target);
38+
}
39+
40+
private static String importFolder(Path source) {
41+
if (source == null || source.getFileName() == null) return null;
42+
String name = source.getFileName().toString().toLowerCase(Locale.ROOT);
43+
int dot = name.lastIndexOf('.');
44+
if (dot <= 0) return null;
45+
return switch (name.substring(dot + 1)) {
46+
case "png", "jpg", "jpeg", "gif" -> "images";
47+
case "wav", "ogg", "mp3", "aif", "aiff", "au" -> "sounds";
48+
default -> null;
49+
};
50+
}
51+
2252
/** Copies the built-in image into assets/images/&lt;name&gt;.png; returns the file. */
2353
public static Path copyBuiltinImage(BuiltinImage entry, Path projectRoot)
2454
throws IOException {

‎core/src/main/java/org/openpatch/scratch4j/core/compile/Completions.java‎

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,68 @@ public record Result(int start, String prefix, boolean memberAccess, List<Item>
7373

7474
private Completions() {}
7575

76+
/**
77+
* What completion keeps between requests of one project: javac's file
78+
* manager (the library jars stay open) and the other sources' texts (read
79+
* again only when the file changed on disk). Used from one thread at a time.
80+
*/
81+
public static final class Cache implements AutoCloseable {
82+
private final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
83+
private StandardJavaFileManager files;
84+
private List<Path> classpath;
85+
private final Map<Path, Text> texts = new java.util.HashMap<>();
86+
87+
private record Text(java.nio.file.attribute.FileTime modified, long size, String text) {}
88+
89+
StandardJavaFileManager files(List<Path> classpath) throws IOException {
90+
if (files == null || !classpath.equals(this.classpath)) {
91+
close();
92+
files = compiler.getStandardFileManager(d -> { }, null, StandardCharsets.UTF_8);
93+
if (!classpath.isEmpty()) {
94+
files.setLocation(StandardLocation.CLASS_PATH,
95+
classpath.stream().map(Path::toFile).collect(Collectors.toList()));
96+
}
97+
this.classpath = List.copyOf(classpath);
98+
}
99+
return files;
100+
}
101+
102+
String read(Path source) throws IOException {
103+
var attributes = Files.readAttributes(source,
104+
java.nio.file.attribute.BasicFileAttributes.class);
105+
Text cached = texts.get(source);
106+
if (cached == null || !cached.modified().equals(attributes.lastModifiedTime())
107+
|| cached.size() != attributes.size()) {
108+
cached = new Text(attributes.lastModifiedTime(), attributes.size(),
109+
Files.readString(source, StandardCharsets.UTF_8));
110+
texts.put(source, cached);
111+
}
112+
return cached.text();
113+
}
114+
115+
void retain(java.util.Collection<Path> sources) {
116+
texts.keySet().retainAll(sources);
117+
}
118+
119+
@Override
120+
public void close() throws IOException {
121+
if (files != null) {
122+
files.close();
123+
files = null;
124+
}
125+
}
126+
}
127+
76128
public static Result complete(Path file, String text, int offset, List<Path> sources,
77129
List<Path> classpath) throws IOException {
130+
try (Cache cache = new Cache()) {
131+
return complete(file, text, offset, sources, classpath, cache);
132+
}
133+
}
134+
135+
/** {@link #complete(Path, String, int, List, List)} reusing what {@code cache} keeps. */
136+
public static Result complete(Path file, String text, int offset, List<Path> sources,
137+
List<Path> classpath, Cache cache) throws IOException {
78138
int start = offset;
79139
while (start > 0 && Character.isJavaIdentifierPart(text.charAt(start - 1))) {
80140
start--;
@@ -95,24 +155,25 @@ public static Result complete(Path file, String text, int offset, List<Path> sou
95155
String filler = restOfLine.isBlank() ? "();" : "";
96156
String probe = text.substring(0, start) + PLACEHOLDER + filler + text.substring(end);
97157

98-
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
158+
JavaCompiler compiler = cache.compiler;
99159
if (compiler == null) {
100160
return new Result(start, prefix, memberAccess, List.of());
101161
}
102-
try (StandardJavaFileManager files = compiler.getStandardFileManager(d -> { }, null,
103-
StandardCharsets.UTF_8)) {
104-
if (!classpath.isEmpty()) {
105-
files.setLocation(StandardLocation.CLASS_PATH,
106-
classpath.stream().map(Path::toFile).collect(Collectors.toList()));
107-
}
162+
{ // the file manager belongs to the cache: it stays open for the next request
163+
StandardJavaFileManager files = cache.files(classpath);
108164
Path current = file.toAbsolutePath().normalize();
109165
List<JavaFileObject> units = new ArrayList<>();
110166
for (Path source : sources) {
111167
Path normalized = source.toAbsolutePath().normalize();
112168
if (!normalized.equals(current)) {
113-
units.add(new Source(normalized, Files.readString(normalized, StandardCharsets.UTF_8)));
169+
try {
170+
units.add(new Source(normalized, cache.read(normalized)));
171+
} catch (java.nio.file.NoSuchFileException gone) {
172+
// deleted since the sources were listed
173+
}
114174
}
115175
}
176+
cache.retain(sources.stream().map(p -> p.toAbsolutePath().normalize()).toList());
116177
Source edited = new Source(current, probe);
117178
units.add(edited);
118179
JavacTask task = (JavacTask) compiler.getTask(null, files, d -> { },

‎core/src/main/java/org/openpatch/scratch4j/core/project/ProjectAssetManagement.java‎

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.HashMap;
2323
import java.util.List;
2424
import java.util.Map;
25+
import java.util.Set;
2526
import java.util.UUID;
2627
import java.util.stream.Stream;
2728

@@ -49,6 +50,12 @@ public static Path rename(ScratchProject project, Path asset, String newBaseName
4950

5051
public static Path rename(ScratchProject project, Path asset, String newBaseName,
5152
FileUsages.Mode mode) throws IOException {
53+
return rename(project, asset, newBaseName, mode, Set.of());
54+
}
55+
56+
/** {@link #rename(ScratchProject, Path, String, FileUsages.Mode)}, leaving the {@code skipped} usages as they are. */
57+
public static Path rename(ScratchProject project, Path asset, String newBaseName,
58+
FileUsages.Mode mode, Set<FileUsages.Usage> skipped) throws IOException {
5259
Path source = validate(project, asset);
5360
if (!newBaseName.matches("[A-Za-z0-9_][A-Za-z0-9_ -]*")) {
5461
throw new IOException("Use letters, digits, spaces, hyphens or underscores");
@@ -60,7 +67,7 @@ public static Path rename(ScratchProject project, Path asset, String newBaseName
6067
if (source.equals(target) || Files.exists(target)) {
6168
throw new IOException("Choose an unused asset name");
6269
}
63-
return relocate(project, source, target, mode);
70+
return relocate(project, source, target, mode, skipped);
6471
}
6572

6673
/**
@@ -98,6 +105,12 @@ static List<FileUsages.Usage> usages(ScratchProject project, Path path) throws I
98105
return distinct(usages);
99106
}
100107

108+
/** Whether the user turned off the usage of this string (its path sits inside the quotes). */
109+
private static boolean isSkipped(Set<FileUsages.Usage> skipped, Path file, Span span) {
110+
return skipped.stream().anyMatch(u -> u.updatable() && u.file().equals(file)
111+
&& u.start() == span.start() + 1);
112+
}
113+
101114
private static List<FileUsages.Usage> distinct(List<FileUsages.Usage> usages) {
102115
Map<String, FileUsages.Usage> seen = new java.util.LinkedHashMap<>();
103116
for (FileUsages.Usage usage : usages) {
@@ -129,15 +142,25 @@ static Path relocate(ScratchProject project, Path source, Path target) throws IO
129142
*/
130143
static Path relocate(ScratchProject project, Path source, Path target, FileUsages.Mode mode)
131144
throws IOException {
132-
Path moved = relocateFiles(project, source, target, mode);
145+
return relocate(project, source, target, mode, Set.of());
146+
}
147+
148+
/**
149+
* {@link #relocate(ScratchProject, Path, Path, FileUsages.Mode)}, leaving the
150+
* {@code skipped} usages (from {@link FileUsages#find}) as they are: the user
151+
* turned them off in the preview.
152+
*/
153+
static Path relocate(ScratchProject project, Path source, Path target, FileUsages.Mode mode,
154+
Set<FileUsages.Usage> skipped) throws IOException {
155+
Path moved = relocateFiles(project, source, target, mode, skipped);
133156
// the editors' undo steps go along
134157
org.openpatch.scratch4j.core.io.EditorHistory.move(project.root().toRealPath(), source,
135158
moved);
136159
return moved;
137160
}
138161

139162
private static Path relocateFiles(ScratchProject project, Path source, Path target,
140-
FileUsages.Mode mode) throws IOException {
163+
FileUsages.Mode mode, Set<FileUsages.Usage> skipped) throws IOException {
141164
boolean folder = Files.isDirectory(source);
142165
if (folder) {
143166
for (Path file : files(source)) {
@@ -154,7 +177,8 @@ private static Path relocateFiles(ScratchProject project, Path source, Path targ
154177
}
155178
Map<Path, String> original = new HashMap<>();
156179
Map<Path, List<Span>> spans = new HashMap<>();
157-
Map<Span, String> replacements = new HashMap<>();
180+
// per file: two files can have a path at the same offsets
181+
Map<Path, Map<Span, String>> replacements = new HashMap<>();
158182
Map<String, String> refs = new HashMap<>();
159183
List<FileUsages.Usage> manual = new ArrayList<>();
160184
for (Path file : files(source)) {
@@ -166,9 +190,13 @@ private static Path relocateFiles(ScratchProject project, Path source, Path targ
166190
manual.addAll(scan.manual());
167191
original.putAll(scan.original());
168192
for (Map.Entry<Path, List<Span>> entry : scan.literals().entrySet()) {
169-
spans.computeIfAbsent(entry.getKey(), key -> new ArrayList<>())
170-
.addAll(entry.getValue());
171-
entry.getValue().forEach(span -> replacements.put(span, "\"" + newRef + "\""));
193+
List<Span> kept = entry.getValue().stream()
194+
.filter(span -> !isSkipped(skipped, entry.getKey(), span))
195+
.toList();
196+
if (kept.isEmpty()) continue;
197+
spans.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()).addAll(kept);
198+
kept.forEach(span -> replacements.computeIfAbsent(entry.getKey(), key -> new HashMap<>())
199+
.put(span, "\"" + newRef + "\""));
172200
}
173201
}
174202
if (folder) {
@@ -184,11 +212,13 @@ private static Path relocateFiles(ScratchProject project, Path source, Path targ
184212
StringBuilder code = new StringBuilder(original.get(entry.getKey()));
185213
entry.getValue().stream().distinct()
186214
.sorted(Comparator.comparingInt(Span::start).reversed())
187-
.forEach(span -> code.replace(span.start(), span.end(), replacements.get(span)));
215+
.forEach(span -> code.replace(span.start(), span.end(),
216+
replacements.get(entry.getKey()).get(span)));
188217
changes.put(entry.getKey(), code.toString());
189218
}
190219
String oldSplash = project.settings().splashLogo;
191-
String newSplash = oldSplash == null ? null : refs.get(oldSplash);
220+
boolean keepSplash = skipped.stream().anyMatch(u -> u.updatable() && u.line() == 0);
221+
String newSplash = oldSplash == null || keepSplash ? null : refs.get(oldSplash);
192222
List<Path> written = new ArrayList<>();
193223
boolean done = false;
194224
try {

‎core/src/main/java/org/openpatch/scratch4j/core/project/ProjectFileManagement.java‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,14 @@ public static Path move(ScratchProject project, Path file, Path folder) throws I
3131
}
3232

3333
/** {@link #move(ScratchProject, Path, Path)}, choosing what happens to the file's usages. */
34-
public static Path move(ScratchProject project, Path file, Path folder, FileUsages.Mode mode)
35-
throws IOException {
34+
public static Path move(ScratchProject project, Path file, Path folder,
35+
FileUsages.Mode mode) throws IOException {
36+
return move(project, file, folder, mode, java.util.Set.of());
37+
}
38+
39+
/** Like the method above, leaving the {@code skipped} usages (turned off in the preview). */
40+
public static Path move(ScratchProject project, Path file, Path folder,
41+
FileUsages.Mode mode, java.util.Set<FileUsages.Usage> skipped) throws IOException {
3642
Path root = project.root().toRealPath();
3743
Path source = movable(project, file);
3844
Path directory = folder.toRealPath();
@@ -55,7 +61,7 @@ public static Path move(ScratchProject project, Path file, Path folder, FileUsag
5561
// back to the project folder: the default package needs no other change
5662
return Files.move(source, target);
5763
}
58-
return ProjectAssetManagement.relocate(project, source, target, mode);
64+
return ProjectAssetManagement.relocate(project, source, target, mode, skipped);
5965
}
6066

6167
/**
@@ -70,6 +76,12 @@ public static Path rename(ScratchProject project, Path file, String newName)
7076
/** {@link #rename(ScratchProject, Path, String)}, choosing what happens to the usages. */
7177
public static Path rename(ScratchProject project, Path file, String newName,
7278
FileUsages.Mode mode) throws IOException {
79+
return rename(project, file, newName, mode, java.util.Set.of());
80+
}
81+
82+
/** Like the method above, leaving the {@code skipped} usages (turned off in the preview). */
83+
public static Path rename(ScratchProject project, Path file, String newName,
84+
FileUsages.Mode mode, java.util.Set<FileUsages.Usage> skipped) throws IOException {
7385
Path source = movable(project, file);
7486
if (source.getFileName().toString().endsWith(".java")) {
7587
throw new IOException("Rename a Java file by renaming its class");
@@ -82,7 +94,7 @@ public static Path rename(ScratchProject project, Path file, String newName,
8294
if (source.equals(target) || Files.exists(target)) {
8395
throw new IOException("Choose an unused file name");
8496
}
85-
return ProjectAssetManagement.relocate(project, source, target, mode);
97+
return ProjectAssetManagement.relocate(project, source, target, mode, skipped);
8698
}
8799

88100
/**

‎core/src/main/java/org/openpatch/scratch4j/core/project/ProjectFolderManagement.java‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,20 @@ public static Path rename(ScratchProject project, Path folder, String newName)
4848
/** {@link #rename(ScratchProject, Path, String)}, choosing what happens to the usages. */
4949
public static Path rename(ScratchProject project, Path folder, String newName,
5050
FileUsages.Mode mode) throws IOException {
51+
return rename(project, folder, newName, mode, java.util.Set.of());
52+
}
53+
54+
/** Like the method above, leaving the {@code skipped} usages (turned off in the preview). */
55+
public static Path rename(ScratchProject project, Path folder, String newName,
56+
FileUsages.Mode mode, java.util.Set<FileUsages.Usage> skipped) throws IOException {
5157
Path source = validate(project, folder);
5258
checkName(newName);
5359
Path target = source.resolveSibling(newName);
5460
if (source.equals(target) || Files.exists(target)) {
5561
throw new IOException("Choose an unused folder name");
5662
}
5763
checkMovable(source);
58-
return ProjectAssetManagement.relocate(project, source, target, mode);
64+
return ProjectAssetManagement.relocate(project, source, target, mode, skipped);
5965
}
6066

6167
/** Moves a folder (with everything in it) into another project folder. */
@@ -67,6 +73,12 @@ public static Path move(ScratchProject project, Path folder, Path newParent)
6773
/** {@link #move(ScratchProject, Path, Path)}, choosing what happens to the usages. */
6874
public static Path move(ScratchProject project, Path folder, Path newParent,
6975
FileUsages.Mode mode) throws IOException {
76+
return move(project, folder, newParent, mode, java.util.Set.of());
77+
}
78+
79+
/** Like the method above, leaving the {@code skipped} usages (turned off in the preview). */
80+
public static Path move(ScratchProject project, Path folder, Path newParent,
81+
FileUsages.Mode mode, java.util.Set<FileUsages.Usage> skipped) throws IOException {
7082
Path source = validate(project, folder);
7183
Path root = project.root().toRealPath();
7284
Path parent = newParent.toRealPath();
@@ -85,7 +97,7 @@ public static Path move(ScratchProject project, Path folder, Path newParent,
8597
throw new IOException("The folder already contains " + source.getFileName());
8698
}
8799
checkMovable(source);
88-
return ProjectAssetManagement.relocate(project, source, target, mode);
100+
return ProjectAssetManagement.relocate(project, source, target, mode, skipped);
89101
}
90102

91103
/** Java classes stay in the project folder (no packages), so their folders stay too. */

0 commit comments

Comments
 (0)