feat: add Open Project modal with project browser, soft delete, and duplicate

Replace the prompt-based project loading with a full modal UI:
- Draggable/resizable panel with overlay and backdrop blur
- Filterable project list with incremental fuzzy matching
- Sortable columns (name, created, updated) with asc/desc toggle
- Duplicate project with auto-rename on name conflict
- Soft delete with trash view, restore, and permanent delete
- Auto-purge trash after 30 days
- Orphan media file cleanup on project open
- Skip folders without valid meta.json in project listing
This commit is contained in:
Xiaohan-Tian
2026-04-10 13:28:01 -07:00
parent 21a19918b5
commit 1ff9bfcac4
5 changed files with 934 additions and 60 deletions
+146 -1
View File
@@ -4,6 +4,7 @@ import { KGProject } from '../KGProject';
import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader';
import { isValidProjectName } from '../../util/projectNameUtil';
import { OPFS_CONSTANTS } from '../../constants/coreConstants';
import { KGAudioRegion } from '../region/KGAudioRegion';
export class DuplicateEntryError extends Error {
constructor(name: string) {
@@ -12,10 +13,11 @@ export class DuplicateEntryError extends Error {
}
}
interface ProjectMeta {
export interface ProjectMeta {
name: string;
createdAt: number;
updatedAt: number;
deletedAt?: number | null;
}
/**
@@ -154,6 +156,137 @@ export class KGProjectStorage {
return names.sort();
}
/**
* List all projects with metadata (name, createdAt, updatedAt).
* Reads each project's meta.json without loading the full project data.
* @param deleted - if true, return only soft-deleted projects; if false (default), return only non-deleted projects.
*/
public async listWithMeta(deleted: boolean = false): Promise<ProjectMeta[]> {
this.ensureInitialized();
const results: ProjectMeta[] = [];
for await (const entry of this.projectsDirHandle!.values()) {
if (entry.kind === 'directory') {
try {
const dirHandle = await this.projectsDirHandle!.getDirectoryHandle(entry.name);
const metaJson = await this.readFile(dirHandle, OPFS_CONSTANTS.METADATA_FILE);
const meta = JSON.parse(metaJson) as ProjectMeta;
const isDeleted = !!meta.deletedAt;
if (isDeleted === deleted) {
results.push(meta);
}
} catch {
// Skip folders without a valid meta.json (e.g. temporary unsaved projects)
}
}
}
return results.sort((a, b) => b.updatedAt - a.updatedAt);
}
/**
* Soft-delete a project by setting deletedAt in its meta.json.
*/
public async softDelete(name: string): Promise<void> {
this.ensureInitialized();
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
const meta = await this.readMeta(projectDir, name);
meta.deletedAt = Date.now();
await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2));
}
/**
* Restore a soft-deleted project by removing deletedAt from its meta.json.
*/
public async restore(name: string): Promise<void> {
this.ensureInitialized();
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
const meta = await this.readMeta(projectDir, name);
delete meta.deletedAt;
await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2));
}
/**
* Duplicate a project under a new name, copying all files including media.
*/
public async duplicate(sourceName: string, targetName: string): Promise<void> {
this.ensureInitialized();
if (!isValidProjectName(targetName)) {
throw new Error(`Invalid project name "${targetName}".`);
}
// Load the source project
const project = await this.load(sourceName);
if (!project) {
throw new Error(`Project "${sourceName}" not found.`);
}
// Save to new location with fresh timestamps
project.setName(targetName);
await this.save(targetName, project, false);
// Copy media files
await this.copyMediaFiles(sourceName, targetName);
}
/**
* Permanently delete all soft-deleted projects older than the given age in milliseconds.
*/
public async purgeDeletedOlderThan(ageMs: number): Promise<void> {
this.ensureInitialized();
const cutoff = Date.now() - ageMs;
const deleted = await this.listWithMeta(true);
for (const meta of deleted) {
if (meta.deletedAt && meta.deletedAt < cutoff) {
await this.delete(meta.name);
}
}
}
/**
* Remove orphan media files that are not referenced by any track in the project.
*/
public async cleanupOrphanMedia(name: string, project: KGProject): Promise<void> {
this.ensureInitialized();
try {
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
let mediaDir: FileSystemDirectoryHandle;
try {
mediaDir = await projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR);
} catch {
return; // No media directory — nothing to clean up
}
// Collect all referenced audio file IDs from the project
const referencedIds = new Set<string>();
for (const track of project.getTracks()) {
for (const region of track.getRegions()) {
if (region instanceof KGAudioRegion) {
const fileId = region.getAudioFileId();
if (fileId) referencedIds.add(fileId);
}
}
}
// Iterate media files and remove orphans
const orphans: string[] = [];
for await (const entry of mediaDir.values()) {
if (entry.kind === 'file' && !referencedIds.has(entry.name)) {
orphans.push(entry.name);
}
}
for (const orphan of orphans) {
await mediaDir.removeEntry(orphan);
console.log(`Removed orphan media file "${orphan}" from project "${name}"`);
}
} catch (error) {
// Non-critical — log but don't throw
console.warn(`Error cleaning up orphan media for project "${name}":`, error);
}
}
/**
* Delete a project and all its files.
*/
@@ -452,4 +585,16 @@ export class KGProjectStorage {
const file = await fileHandle.getFile();
return file.text();
}
private async readMeta(
projectDir: FileSystemDirectoryHandle,
name: string,
): Promise<ProjectMeta> {
try {
const raw = await this.readFile(projectDir, OPFS_CONSTANTS.METADATA_FILE);
return JSON.parse(raw) as ProjectMeta;
} catch {
return { name, createdAt: 0, updatedAt: 0 };
}
}
}