diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index b9b155f..c1c5649 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -30,6 +30,7 @@ import PianoIcon from './common/icons/PianoIcon'; const Toolbar: React.FC = () => { const { projectName, setProjectName, + savedProjectName, setSavedProjectName, bpm, timeSignature, keySignature, setStatus, isPlaying, startPlaying, stopPlaying, setPlayheadPosition, currentTime, setBpm, setTimeSignature, setKeySignature, @@ -73,25 +74,25 @@ const Toolbar: React.FC = () => { }; // Common project loading logic extracted for reuse - const loadProjectFromData = async (project: KGProject, sourceDescription: string) => { + const loadProjectFromData = async (project: KGProject, sourceDescription: string, savedName?: string) => { try { // Clean up UI state first cleanupProjectState(); - + // Automatically clear chat history when loading a project clearChatHistoryAndUI(); - + // Load the project using the store's loadProject method const { loadProject: storeLoadProject } = useProjectStore.getState(); - await storeLoadProject(project); - + await storeLoadProject(project, savedName); + // Update status to indicate project loaded setStatus(`${sourceDescription} loaded successfully`); - + if (DEBUG_MODE.TOOLBAR) { console.log(`project loaded successfully from ${sourceDescription}`); } - + } catch (error) { console.error(`Error loading project from ${sourceDescription}:`, error); setStatus(`Failed to load project: ${error}`); @@ -159,8 +160,8 @@ const Toolbar: React.FC = () => { return; } - // Use common loading logic - await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`); + // Use common loading logic — pass the OPFS folder name so savedProjectName is set correctly + await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad.trim()); } catch (error) { console.error("Error loading project:", error); @@ -174,7 +175,12 @@ const Toolbar: React.FC = () => { console.log("user clicked save button"); } - await saveProject(projectName, setStatus); + await saveProject(projectName, savedProjectName, setStatus, (finalName) => { + setSavedProjectName(finalName); + if (finalName !== projectName) { + setProjectName(finalName); + } + }); }; const handleExportProject = (exportType: string) => { @@ -317,7 +323,7 @@ const Toolbar: React.FC = () => { throw new Error('Failed to load imported project'); } - await loadProjectFromData(loaded, `KGStudio file "${file.name}"`); + await loadProjectFromData(loaded, `KGStudio file "${file.name}"`, projectName); if (DEBUG_MODE.TOOLBAR) { console.log("KGStudio file imported successfully:", projectName); @@ -357,7 +363,7 @@ const Toolbar: React.FC = () => { throw new Error('Failed to load imported project from storage'); } - await loadProjectFromData(loaded, `JSON file "${file.name}"`); + await loadProjectFromData(loaded, `JSON file "${file.name}"`, importedName); if (DEBUG_MODE.TOOLBAR) { console.log("KGStudio JSON project imported and saved to OPFS:", importedName); diff --git a/src/components/track/Region.css b/src/components/track/Region.css index d9629dc..9175075 100644 --- a/src/components/track/Region.css +++ b/src/components/track/Region.css @@ -85,6 +85,10 @@ box-shadow: 0 0 0 1px #6aab7a; } +.track-region.audio-region.selected { + border-color: #ffffff; +} + .track-region.audio-region .region-header { background-color: #4a8b5a; } diff --git a/src/core/io/KGAudioFileStorage.ts b/src/core/io/KGAudioFileStorage.ts index aca87f5..208e539 100644 --- a/src/core/io/KGAudioFileStorage.ts +++ b/src/core/io/KGAudioFileStorage.ts @@ -7,13 +7,14 @@ import { OPFS_CONSTANTS } from '../../constants/coreConstants'; export class KGAudioFileStorage { /** * Store an audio file in the project's media/ directory. + * Creates the project and media directories if they don't exist yet. */ public static async storeAudioFile( projectName: string, fileId: string, file: File ): Promise { - const mediaDir = await KGAudioFileStorage.getMediaDir(projectName); + const mediaDir = await KGAudioFileStorage.getOrCreateMediaDir(projectName); const fileHandle = await mediaDir.getFileHandle(fileId, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(await file.arrayBuffer()); @@ -61,7 +62,8 @@ export class KGAudioFileStorage { } /** - * Get the media directory handle for a project. + * Get the media directory handle for an existing project (read/delete path). + * Does NOT create directories — fails if project folder doesn't exist. */ private static async getMediaDir(projectName: string): Promise { const root = await navigator.storage.getDirectory(); @@ -69,4 +71,15 @@ export class KGAudioFileStorage { const projectDir = await projectsDir.getDirectoryHandle(projectName); return projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true }); } + + /** + * Get or create the media directory handle for a project (write path). + * Creates project and media directories if they don't exist. + */ + private static async getOrCreateMediaDir(projectName: string): Promise { + const root = await navigator.storage.getDirectory(); + const projectsDir = await root.getDirectoryHandle(OPFS_CONSTANTS.ROOT_DIR, { create: true }); + const projectDir = await projectsDir.getDirectoryHandle(projectName, { create: true }); + return projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true }); + } } diff --git a/src/core/io/KGProjectStorage.ts b/src/core/io/KGProjectStorage.ts index ed92db8..3fc296d 100644 --- a/src/core/io/KGProjectStorage.ts +++ b/src/core/io/KGProjectStorage.ts @@ -206,10 +206,31 @@ export class KGProjectStorage { project.setName(newName); await this.save(newName, project, false); + // Copy media files from old to new location + await this.copyMediaFiles(oldName, newName); + // Delete old location await this.delete(oldName); } + /** + * Save a project under a new name, migrating media files from the old folder. + * Used when the user renames the project and saves. Handles the case where the + * old folder doesn't exist yet (new project never saved). + */ + public async saveWithRename(oldName: string, newName: string, data: KGProject): Promise { + this.ensureInitialized(); + + // Save project JSON to the new folder + await this.save(newName, data, false); + + // Migrate media files only if the old folder exists + if (await this.exists(oldName)) { + await this.copyMediaFiles(oldName, newName); + await this.delete(oldName); + } + } + /** * Export a project folder as a zip Blob (.kgstudio bundle). * Includes project.json, meta.json, and all files in media/. @@ -374,6 +395,42 @@ export class KGProjectStorage { return candidate; } + // --- Media migration --- + + /** + * Copy all files from projects//media/ to projects//media/. + * If the source media directory doesn't exist, returns without error. + */ + private async copyMediaFiles(fromName: string, toName: string): Promise { + try { + const fromDir = await this.projectsDirHandle!.getDirectoryHandle(fromName); + let fromMedia: FileSystemDirectoryHandle; + try { + fromMedia = await fromDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR); + } catch { + // No media directory in source — nothing to copy + return; + } + + const toDir = await this.projectsDirHandle!.getDirectoryHandle(toName); + const toMedia = await toDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true }); + + for await (const entry of fromMedia.values()) { + if (entry.kind === 'file') { + const fileHandle = entry as FileSystemFileHandle; + const file = await fileHandle.getFile(); + const newHandle = await toMedia.getFileHandle(entry.name, { create: true }); + const writable = await newHandle.createWritable(); + await writable.write(await file.arrayBuffer()); + await writable.close(); + } + } + } catch (error) { + console.error(`Error copying media files from "${fromName}" to "${toName}":`, error); + throw error; + } + } + // --- File I/O helpers --- private async writeFile( diff --git a/src/hooks/useGlobalKeyboardHandler.ts b/src/hooks/useGlobalKeyboardHandler.ts index 3ab7ae8..3d0d916 100644 --- a/src/hooks/useGlobalKeyboardHandler.ts +++ b/src/hooks/useGlobalKeyboardHandler.ts @@ -11,7 +11,7 @@ import { selectAllNotesInActiveRegion } from '../util/selectionUtil'; * Handles keyboard shortcuts defined in the configuration */ export const useGlobalKeyboardHandler = () => { - const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName } = useProjectStore(); + const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName } = useProjectStore(); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -39,7 +39,10 @@ export const useGlobalKeyboardHandler = () => { // Early return to handle save immediately try { - saveProject(projectName, setStatus); + saveProject(projectName, savedProjectName, setStatus, (finalName) => { + setSavedProjectName(finalName); + if (finalName !== projectName) setProjectName(finalName); + }); } catch (error) { console.error('Save failed:', error); setStatus('Save failed'); @@ -154,7 +157,10 @@ export const useGlobalKeyboardHandler = () => { if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) { event.preventDefault(); try { - saveProject(projectName, setStatus); + saveProject(projectName, savedProjectName, setStatus, (finalName) => { + setSavedProjectName(finalName); + if (finalName !== projectName) setProjectName(finalName); + }); } catch (error) { console.error('Save failed:', error); setStatus('Save failed'); diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index cbb9e27..451a439 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -38,6 +38,7 @@ function updateMaxBarsCSS(maxBars: number): void { interface ProjectState { // State projectName: string; + savedProjectName: string; // OPFS folder name where the project is currently saved tracks: KGTrack[]; currentStatus: string; maxBars: number; @@ -82,6 +83,7 @@ interface ProjectState { // Actions setProjectName: (name: string) => void; + setSavedProjectName: (name: string) => void; addTrack: () => Promise; addAudioTrack: () => Promise; importAudioToTrack: (trackId: string, file: File) => Promise; @@ -95,7 +97,7 @@ interface ProjectState { setStatus: (status: string) => void; removeStatus: () => void; refreshStatus: () => void; - loadProject: (project: KGProject | null) => Promise; + loadProject: (project: KGProject | null, savedName?: string) => Promise; setPlayheadPosition: (position: number) => void; startPlaying: () => Promise; stopPlaying: () => Promise; @@ -234,6 +236,7 @@ export const useProjectStore = create((set, get) => { return { // Initial state projectName: currentProject.getName(), + savedProjectName: currentProject.getName(), tracks: currentProject.getTracks() as KGTrack[], currentStatus: KGCore.instance().getStatus() || 'Unknown', maxBars: currentProject.getMaxBars(), @@ -281,10 +284,10 @@ export const useProjectStore = create((set, get) => { // Create and execute the change project property command const command = new ChangeProjectPropertyCommand({ name }); KGCore.instance().executeCommand(command); - + // Update the store state set({ projectName: name }); - + console.log(`Set project name to "${name}"`); } catch (error) { console.error('Error setting project name:', error); @@ -292,6 +295,10 @@ export const useProjectStore = create((set, get) => { } }, + setSavedProjectName: (name: string) => { + set({ savedProjectName: name }); + }, + addTrack: async () => { try { // Create and execute the add track command @@ -579,7 +586,7 @@ export const useProjectStore = create((set, get) => { set({ currentStatus: KGCore.instance().getStatus() || 'Unknown' }); }, - loadProject: async (project: KGProject | null = null) => { + loadProject: async (project: KGProject | null = null, savedName?: string) => { try { const { setPlayheadPosition } = get(); @@ -675,6 +682,7 @@ export const useProjectStore = create((set, get) => { // Force a new array reference for tracks to trigger React/Zustand re-render set({ projectName: projectToLoad.getName(), + savedProjectName: savedName ?? projectToLoad.getName(), tracks: [...tracks], maxBars, timeSignature, diff --git a/src/util/saveUtil.ts b/src/util/saveUtil.ts index 780e16a..a394744 100644 --- a/src/util/saveUtil.ts +++ b/src/util/saveUtil.ts @@ -2,58 +2,79 @@ import { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStora import { KGCore } from '../core/KGCore'; /** - * Save project utility function - * Handles saving the current project with proper error handling and user confirmation - * @param projectName - The name of the project to save - * @param setStatus - Function to update the status message - * @returns Promise - Returns true if save was successful, false otherwise + * Save project utility function. + * Detects renames (savedProjectName !== projectName) and migrates the OPFS folder, + * including media files. Duplicate name conflicts during rename auto-resolve with + * the {name} (1), {name} (2), ... pattern. + * + * @param projectName Current in-memory project name + * @param savedProjectName OPFS folder name the project was last saved under + * @param setStatus Function to update the status message + * @param onSaveSuccess Called with the final saved name on success */ export const saveProject = async ( projectName: string, - setStatus: (status: string) => void + savedProjectName: string, + setStatus: (status: string) => void, + onSaveSuccess: (finalName: string) => void, ): Promise => { const storage = KGProjectStorage.getInstance(); + const isRename = savedProjectName !== projectName; + if (isRename) { + // Determine the target name, resolving conflicts automatically + let targetName = projectName; + if (await storage.exists(projectName)) { + targetName = await storage.resolveUniqueName(projectName); + } + + try { + await storage.saveWithRename( + savedProjectName, + targetName, + KGCore.instance().getCurrentProject(), + ); + + const statusMsg = + targetName !== projectName + ? `Project renamed to "${targetName}" and saved` + : `Project "${targetName}" has been saved`; + setStatus(statusMsg); + onSaveSuccess(targetName); + return true; + } catch (error) { + console.error('Error saving renamed project:', error); + window.alert(`An error occurred while saving: ${error}`); + return false; + } + } + + // Same name — existing overwrite logic try { - await storage.save( - projectName, - KGCore.instance().getCurrentProject(), - false, - ); - + await storage.save(projectName, KGCore.instance().getCurrentProject(), false); setStatus(`Project "${projectName}" has been saved`); - console.log("project saved successfully"); + onSaveSuccess(projectName); return true; - } catch (error) { - console.error("Error saving project:", error); - if (error instanceof DuplicateEntryError) { - const confirmed = window.confirm(`Project "${projectName}" already exists. Do you want to overwrite it?`); - + const confirmed = window.confirm( + `Project "${projectName}" already exists. Do you want to overwrite it?`, + ); if (confirmed) { try { - await storage.save( - projectName, - KGCore.instance().getCurrentProject(), - true, - ); - + await storage.save(projectName, KGCore.instance().getCurrentProject(), true); setStatus(`Project "${projectName}" has been saved`); - console.log("project saved successfully after overwrite"); + onSaveSuccess(projectName); return true; - } catch (overwriteError) { - console.error("Error overwriting project:", overwriteError); + console.error('Error overwriting project:', overwriteError); window.alert(`An error occurred while overwriting the project: ${overwriteError}`); return false; } - } else { - // User cancelled the overwrite - return false; } + return false; } else { - console.error("Error saving project:", error); + console.error('Error saving project:', error); window.alert(`An unknown error ${error} occurred. Please try again.`); return false; }