···11+package app.project;
22+33+import java.io.File;
44+import java.io.FileReader;
55+import java.io.FileWriter;
66+import java.io.IOException;
77+import java.lang.reflect.Type;
88+import java.util.ArrayList;
99+import java.util.Collections;
1010+import java.util.Iterator;
1111+import java.util.List;
1212+1313+import com.google.gson.Gson;
1414+import com.google.gson.GsonBuilder;
1515+import com.google.gson.reflect.TypeToken;
1616+1717+import app.Environment;
1818+import util.Logger;
1919+2020+/**
2121+ * JSON-based implementation of ProjectRepository.
2222+ * Stores projects in projects.json in the user config directory.
2323+ */
2424+public class JsonProjectRepository implements ProjectRepository
2525+{
2626+ private static final String PROJECTS_FILE = "projects.json";
2727+2828+ private final File projectsFile;
2929+ private final Gson gson;
3030+3131+ public JsonProjectRepository()
3232+ {
3333+ this.projectsFile = new File(Environment.getUserConfigDir(), PROJECTS_FILE);
3434+ this.gson = new GsonBuilder()
3535+ .setPrettyPrinting()
3636+ .create();
3737+ }
3838+3939+ @Override
4040+ public synchronized List<Project> getAllProjects()
4141+ {
4242+ List<ProjectData> dataList = loadProjectData();
4343+ List<Project> projects = new ArrayList<>();
4444+4545+ // Convert to Project objects, filtering out invalid entries
4646+ Iterator<ProjectData> iter = dataList.iterator();
4747+ boolean modified = false;
4848+4949+ while (iter.hasNext()) {
5050+ ProjectData data = iter.next();
5151+ File path = new File(data.path);
5252+5353+ // Remove invalid entries
5454+ if (!path.exists()) {
5555+ iter.remove();
5656+ modified = true;
5757+ continue;
5858+ }
5959+6060+ projects.add(new Project(path, data.lastOpened));
6161+ }
6262+6363+ // Save if we removed any invalid entries
6464+ if (modified) {
6565+ saveProjectData(dataList);
6666+ }
6767+6868+ // Sort by last opened (most recent first)
6969+ Collections.sort(projects);
7070+ return projects;
7171+ }
7272+7373+ @Override
7474+ public synchronized void addProject(Project project)
7575+ {
7676+ List<ProjectData> dataList = loadProjectData();
7777+7878+ // Remove existing entry with same path (will be re-added with new timestamp)
7979+ String absolutePath = project.getPath().getAbsolutePath();
8080+ dataList.removeIf(data -> data.path.equals(absolutePath));
8181+8282+ // Add new entry
8383+ ProjectData newData = new ProjectData();
8484+ newData.path = absolutePath;
8585+ newData.lastOpened = project.getLastOpened();
8686+ dataList.add(0, newData); // Add to beginning (most recent)
8787+8888+ saveProjectData(dataList);
8989+ }
9090+9191+ @Override
9292+ public synchronized void removeProject(File projectPath)
9393+ {
9494+ List<ProjectData> dataList = loadProjectData();
9595+ String absolutePath = projectPath.getAbsolutePath();
9696+ dataList.removeIf(data -> data.path.equals(absolutePath));
9797+ saveProjectData(dataList);
9898+ }
9999+100100+ @Override
101101+ public synchronized void updateLastOpened(File projectPath)
102102+ {
103103+ List<ProjectData> dataList = loadProjectData();
104104+ String absolutePath = projectPath.getAbsolutePath();
105105+106106+ for (ProjectData data : dataList) {
107107+ if (data.path.equals(absolutePath)) {
108108+ data.lastOpened = System.currentTimeMillis();
109109+ saveProjectData(dataList);
110110+ return;
111111+ }
112112+ }
113113+114114+ // Project not found, add it
115115+ ProjectData newData = new ProjectData();
116116+ newData.path = absolutePath;
117117+ newData.lastOpened = System.currentTimeMillis();
118118+ dataList.add(0, newData);
119119+ saveProjectData(dataList);
120120+ }
121121+122122+ private List<ProjectData> loadProjectData()
123123+ {
124124+ if (!projectsFile.exists()) {
125125+ return new ArrayList<>();
126126+ }
127127+128128+ try (FileReader reader = new FileReader(projectsFile)) {
129129+ Type listType = new TypeToken<List<ProjectData>>() {}.getType();
130130+ List<ProjectData> data = gson.fromJson(reader, listType);
131131+ return data != null ? data : new ArrayList<>();
132132+ }
133133+ catch (IOException e) {
134134+ Logger.logError("Failed to read projects file: " + e.getMessage());
135135+ return new ArrayList<>();
136136+ }
137137+ }
138138+139139+ private void saveProjectData(List<ProjectData> dataList)
140140+ {
141141+ try {
142142+ // Ensure parent directory exists
143143+ projectsFile.getParentFile().mkdirs();
144144+145145+ try (FileWriter writer = new FileWriter(projectsFile)) {
146146+ gson.toJson(dataList, writer);
147147+ }
148148+ }
149149+ catch (IOException e) {
150150+ Logger.logError("Failed to save projects file: " + e.getMessage());
151151+ }
152152+ }
153153+154154+ /**
155155+ * Internal data class for JSON serialization.
156156+ */
157157+ private static class ProjectData
158158+ {
159159+ String path;
160160+ long lastOpened;
161161+ }
162162+}
+79
src/main/java/app/project/Project.java
···11+package app.project;
22+33+import java.io.File;
44+import java.util.Objects;
55+66+/**
77+ * Immutable data class representing a Star Rod project.
88+ * Stores the project path and last opened timestamp.
99+ */
1010+public class Project implements Comparable<Project>
1111+{
1212+ private final File path;
1313+ private final long lastOpened;
1414+1515+ public Project(File path, long lastOpened)
1616+ {
1717+ Objects.requireNonNull(path, "Project path cannot be null");
1818+ this.path = path.getAbsoluteFile();
1919+ this.lastOpened = lastOpened;
2020+ }
2121+2222+ public Project(File path)
2323+ {
2424+ this(path, System.currentTimeMillis());
2525+ }
2626+2727+ public File getPath()
2828+ {
2929+ return path;
3030+ }
3131+3232+ public String getName()
3333+ {
3434+ return path.getName();
3535+ }
3636+3737+ public long getLastOpened()
3838+ {
3939+ return lastOpened;
4040+ }
4141+4242+ /**
4343+ * Creates a new Project instance with updated lastOpened timestamp.
4444+ */
4545+ public Project withLastOpened(long timestamp)
4646+ {
4747+ return new Project(path, timestamp);
4848+ }
4949+5050+ @Override
5151+ public int compareTo(Project other)
5252+ {
5353+ // Sort by lastOpened descending (most recent first)
5454+ return Long.compare(other.lastOpened, this.lastOpened);
5555+ }
5656+5757+ @Override
5858+ public boolean equals(Object obj)
5959+ {
6060+ if (this == obj)
6161+ return true;
6262+ if (obj == null || getClass() != obj.getClass())
6363+ return false;
6464+ Project other = (Project) obj;
6565+ return path.equals(other.path);
6666+ }
6767+6868+ @Override
6969+ public int hashCode()
7070+ {
7171+ return path.hashCode();
7272+ }
7373+7474+ @Override
7575+ public String toString()
7676+ {
7777+ return getName() + " (" + path.getAbsolutePath() + ")";
7878+ }
7979+}
+114
src/main/java/app/project/ProjectManager.java
···11+package app.project;
22+33+import java.io.File;
44+import java.io.IOException;
55+import java.util.List;
66+77+import org.apache.commons.io.FileUtils;
88+99+import app.Environment;
1010+import util.Logger;
1111+1212+/**
1313+ * Use case class for project operations.
1414+ * Orchestrates project repository operations and integrates with Environment.
1515+ */
1616+public class ProjectManager
1717+{
1818+ private static ProjectManager instance;
1919+2020+ private final ProjectRepository repository;
2121+2222+ private ProjectManager(ProjectRepository repository)
2323+ {
2424+ this.repository = repository;
2525+ }
2626+2727+ /**
2828+ * Gets the singleton instance of ProjectManager.
2929+ */
3030+ public static synchronized ProjectManager getInstance()
3131+ {
3232+ if (instance == null) {
3333+ instance = new ProjectManager(new JsonProjectRepository());
3434+ }
3535+ return instance;
3636+ }
3737+3838+ /**
3939+ * Gets all recent projects, sorted by last opened (most recent first).
4040+ * Invalid projects (non-existent paths) are automatically removed.
4141+ */
4242+ public List<Project> getRecentProjects()
4343+ {
4444+ return repository.getAllProjects();
4545+ }
4646+4747+ /**
4848+ * Records that a project was opened (adds or updates its timestamp).
4949+ * @param projectPath The path to the project
5050+ */
5151+ public void recordProjectOpened(File projectPath)
5252+ {
5353+ repository.updateLastOpened(projectPath);
5454+ }
5555+5656+ /**
5757+ * Removes a project from the recent projects list.
5858+ * Does NOT delete files from disk.
5959+ * @param project The project to remove
6060+ */
6161+ public void removeFromHistory(Project project)
6262+ {
6363+ repository.removeProject(project.getPath());
6464+ }
6565+6666+ /**
6767+ * Deletes a project from disk and removes it from the history.
6868+ * @param project The project to delete
6969+ * @return true if deletion was successful, false otherwise
7070+ */
7171+ public boolean deleteFromDisk(Project project)
7272+ {
7373+ File projectDir = project.getPath();
7474+7575+ // First remove from history
7676+ repository.removeProject(projectDir);
7777+7878+ // Then delete from disk
7979+ if (projectDir.exists()) {
8080+ try {
8181+ FileUtils.deleteDirectory(projectDir);
8282+ Logger.log("Deleted project directory: " + projectDir.getAbsolutePath());
8383+ return true;
8484+ }
8585+ catch (IOException e) {
8686+ Logger.logError("Failed to delete project: " + e.getMessage());
8787+ return false;
8888+ }
8989+ }
9090+ return true; // Already doesn't exist
9191+ }
9292+9393+ /**
9494+ * Checks if a directory is a valid Star Rod project.
9595+ */
9696+ public boolean isValidProject(File dir)
9797+ {
9898+ return ProjectValidator.isValidProject(dir);
9999+ }
100100+101101+ /**
102102+ * Loads a project using Environment.loadProject().
103103+ * @param projectPath The path to the project
104104+ * @return true if the project was loaded successfully
105105+ */
106106+ public boolean openProject(File projectPath) throws IOException
107107+ {
108108+ boolean success = Environment.loadProject(projectPath);
109109+ if (success) {
110110+ recordProjectOpened(projectPath);
111111+ }
112112+ return success;
113113+ }
114114+}
+34
src/main/java/app/project/ProjectRepository.java
···11+package app.project;
22+33+import java.io.File;
44+import java.util.List;
55+66+/**
77+ * Interface for project persistence operations.
88+ */
99+public interface ProjectRepository
1010+{
1111+ /**
1212+ * Gets all projects sorted by last opened (most recent first).
1313+ * @return List of projects, or empty list if none exist
1414+ */
1515+ List<Project> getAllProjects();
1616+1717+ /**
1818+ * Adds a project to the repository or updates its timestamp if it already exists.
1919+ * @param project The project to add or update
2020+ */
2121+ void addProject(Project project);
2222+2323+ /**
2424+ * Removes a project from the repository.
2525+ * @param projectPath The path of the project to remove
2626+ */
2727+ void removeProject(File projectPath);
2828+2929+ /**
3030+ * Updates the last opened timestamp for a project.
3131+ * @param projectPath The path of the project to update
3232+ */
3333+ void updateLastOpened(File projectPath);
3434+}
+54
src/main/java/app/project/ProjectValidator.java
···11+package app.project;
22+33+import java.io.File;
44+55+import app.Environment;
66+import app.config.Options;
77+88+/**
99+ * Validates whether a directory is a valid Star Rod project.
1010+ * A valid project must have a splat.yaml file in ver/{gameVersion}/.
1111+ */
1212+public class ProjectValidator
1313+{
1414+ private static final String FN_SPLAT = "splat.yaml";
1515+1616+ /**
1717+ * Checks if a directory is a valid Star Rod project.
1818+ * @param dir The directory to check
1919+ * @return true if the directory contains a valid project structure
2020+ */
2121+ public static boolean isValidProject(File dir)
2222+ {
2323+ if (dir == null || !dir.exists() || !dir.isDirectory()) {
2424+ return false;
2525+ }
2626+2727+ // Get game version from config (default "us")
2828+ String gameVersion = "us";
2929+ if (Environment.mainConfig != null) {
3030+ String configVersion = Environment.mainConfig.getString(Options.GameVersion);
3131+ if (configVersion != null && !configVersion.isEmpty()) {
3232+ gameVersion = configVersion;
3333+ }
3434+ }
3535+3636+ // Check for splat.yaml in ver/{gameVersion}/
3737+ File versionDir = new File(dir, "ver/" + gameVersion);
3838+ if (!versionDir.exists() || !versionDir.isDirectory()) {
3939+ return false;
4040+ }
4141+4242+ File splatFile = new File(versionDir, FN_SPLAT);
4343+ return splatFile.exists();
4444+ }
4545+4646+ /**
4747+ * Checks if the current working directory is a valid Star Rod project.
4848+ * @return true if cwd contains a valid project structure
4949+ */
5050+ public static boolean isCurrentDirectoryProject()
5151+ {
5252+ return isValidProject(new File("."));
5353+ }
5454+}