From d3f645446ef9bcf25735daf5238bd95c1e78b85c Mon Sep 17 00:00:00 2001 From: bzeiss Date: Tue, 25 Aug 2026 10:56:02 +0200 Subject: [PATCH 01/19] UI consistency fixes in the import audio dialog. #36 --- .../lib/components/AudioImportDialog.svelte | 55 +++++++++++-------- .../lib/components/AudioImportDialog.test.ts | 26 +++++++++ 2 files changed, 58 insertions(+), 23 deletions(-) diff --git a/apps/axkdeck/src/lib/components/AudioImportDialog.svelte b/apps/axkdeck/src/lib/components/AudioImportDialog.svelte index d102c1c5..7b53682d 100644 --- a/apps/axkdeck/src/lib/components/AudioImportDialog.svelte +++ b/apps/axkdeck/src/lib/components/AudioImportDialog.svelte @@ -481,25 +481,25 @@ /> {:else}
- + + {#if importMode === 'SAMPLE_BANK'} - - {#if sampleBankError}{/if} + + + {#if sampleBankError} + + {/if} {/if}
{#if stagingError} @@ -562,20 +562,21 @@ } .import-target-settings { display: grid; - grid-template-columns: minmax(240px, 360px); - gap: 8px; + grid-template-columns: max-content minmax(240px, 360px) max-content minmax(180px, 1fr); + align-items: center; + gap: 6px 8px; } - .import-target-settings label { - display: grid; - gap: 5px; + .import-target-settings > label { color: var(--color-text-muted); font-size: var(--dialog-label-font-size); + white-space: nowrap; } .import-target-settings select, .import-target-settings input { width: 100%; } .field-error { + grid-column: 4; margin: 0; color: var(--color-danger); font-size: var(--dialog-body-font-size); @@ -590,4 +591,12 @@ width: calc(100vw - 24px); } } + @media (max-width: 760px) { + .import-target-settings { + grid-template-columns: max-content minmax(0, 1fr); + } + .field-error { + grid-column: 2; + } + } diff --git a/apps/axkdeck/src/lib/components/AudioImportDialog.test.ts b/apps/axkdeck/src/lib/components/AudioImportDialog.test.ts index 5d8c438f..f3f57a43 100644 --- a/apps/axkdeck/src/lib/components/AudioImportDialog.test.ts +++ b/apps/axkdeck/src/lib/components/AudioImportDialog.test.ts @@ -107,6 +107,32 @@ describe('AudioImportDialog', () => { expect(audioImportDialogSource).toMatch(/\.audio-import-body\s*\{[^}]*gap:\s*10px;/s); }); + it('keeps the import mode and conditional Sample Bank name in one compact shared-control row', async () => { + render(AudioImportDialog, { + props: { + transport: transport(), + files: [new File([new Uint8Array(64)], 'Bass.wav', { type: 'audio/wav' })], + ...destinationProps('Sounds'), + existingSampleNames: [], + existingSampleBankNames: [], + existingWaveformNames: [], + oncommit: vi.fn(), + oncancel: vi.fn(), + }, + }); + + const mode = screen.getByRole('combobox', { name: 'Import mode' }); + expect(mode.classList).toContain('dialog-field-control'); + await fireEvent.change(mode, { target: { value: 'SAMPLE_BANK' } }); + expect(screen.getByRole('textbox', { name: 'Sample Bank name' }).classList).toContain('dialog-field-control'); + expect(audioImportDialogSource).toMatch( + /\.import-target-settings\s*\{[^}]*grid-template-columns:\s*max-content minmax\(240px, 360px\) max-content minmax\(180px, 1fr\);[^}]*align-items:\s*center;/s, + ); + expect(audioImportDialogSource).toMatch( + /@media \(max-width: 760px\)\s*\{[^}]*\.import-target-settings\s*\{[^}]*grid-template-columns:\s*max-content minmax\(0, 1fr\);/s, + ); + }); + it('left-packs sampler fields independently from the identity columns', () => { expect(audioSamplerSettingsSource).toMatch( /\.settings-fields\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;[^}]*justify-content:\s*flex-start;/s, From 9ada521c76326cb2347013df14556fbf9017336a Mon Sep 17 00:00:00 2001 From: bzeiss Date: Tue, 25 Aug 2026 11:11:57 +0200 Subject: [PATCH 02/19] Dwell delay for used space mouse over in left zone. #37 --- apps/axkdeck/src/lib/components/TreeNode.svelte | 9 ++++++++- apps/axkdeck/src/lib/components/TreeNode.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/axkdeck/src/lib/components/TreeNode.svelte b/apps/axkdeck/src/lib/components/TreeNode.svelte index dbde411f..8eacbb91 100644 --- a/apps/axkdeck/src/lib/components/TreeNode.svelte +++ b/apps/axkdeck/src/lib/components/TreeNode.svelte @@ -468,11 +468,18 @@ transition: opacity 100ms ease, transform 100ms ease; + transition-delay: 0ms; + } + + .tree-row:hover > .tree-item-stack > .tree-item-tooltip { + opacity: 1; + transform: translateY(0); + transition-delay: 500ms; } - .tree-row:hover > .tree-item-stack > .tree-item-tooltip, .tree-item-select:focus-visible ~ .tree-item-tooltip { opacity: 1; transform: translateY(0); + transition-delay: 0ms; } diff --git a/apps/axkdeck/src/lib/components/TreeNode.test.ts b/apps/axkdeck/src/lib/components/TreeNode.test.ts index 7ffb257f..4556b926 100644 --- a/apps/axkdeck/src/lib/components/TreeNode.test.ts +++ b/apps/axkdeck/src/lib/components/TreeNode.test.ts @@ -327,6 +327,16 @@ describe('TreeNode', () => { expect(treeNodeSource).toContain('.tree-item-select:focus-visible ~ .tree-item-tooltip'); }); + it('delays pointer tooltips without delaying keyboard focus or dismissal', () => { + expect(treeNodeSource).toMatch( + /\.tree-row:hover\s*>\s*\.tree-item-stack\s*>\s*\.tree-item-tooltip\s*\{[^}]*transition-delay:\s*500ms;/s, + ); + expect(treeNodeSource).toMatch( + /\.tree-item-select:focus-visible\s*~\s*\.tree-item-tooltip\s*\{[^}]*transition-delay:\s*0ms;/s, + ); + expect(treeNodeSource).toMatch(/\.tree-item-tooltip\s*\{[^}]*transition-delay:\s*0ms;/s); + }); + it('opens volume actions from pointer and keyboard context requests', async () => { const onrequestmenu = vi.fn(); render(TreeNode, { From b9879b89bc7b81b937b3eab8ae245efab053aad5 Mon Sep 17 00:00:00 2001 From: bzeiss Date: Tue, 25 Aug 2026 16:12:05 +0200 Subject: [PATCH 03/19] Destintation selector for midi import / .mid drag & drop. #31 --- apps/axkdeck/src/App.svelte | 6 +- apps/axkdeck/src/App.test.ts | 30 +-- apps/axkdeck/src/app.css | 4 + .../src/features/dialogs/AppDialogs.svelte | 17 +- .../features/import/audioWorkflow.svelte.ts | 4 +- .../import/mediaDropWorkflow.svelte.ts | 28 +-- .../features/import/packageDestinations.ts | 8 +- .../import/sequenceWorkflow.svelte.ts | 185 +++++++++++++++--- .../features/workspace/WorkspaceShell.svelte | 4 +- .../lib/components/AudioImportDialog.svelte | 4 +- .../lib/components/MidiImportDialog.svelte | 70 +++++-- .../lib/components/MidiImportDialog.test.ts | 83 +++++++- .../src/lib/httpImportOperations.test.ts | 41 ++++ apps/axkdeck/src/lib/httpImportOperations.ts | 44 +++-- apps/axkdeck/src/lib/httpTransport.test.ts | 2 +- apps/axkdeck/src/lib/httpTransport.ts | 7 +- .../src/lib/testing/inMemoryTransport.ts | 7 +- apps/axkdeck/src/lib/transport.ts | 11 +- 18 files changed, 414 insertions(+), 141 deletions(-) diff --git a/apps/axkdeck/src/App.svelte b/apps/axkdeck/src/App.svelte index ffaefe45..508df218 100644 --- a/apps/axkdeck/src/App.svelte +++ b/apps/axkdeck/src/App.svelte @@ -233,8 +233,9 @@ picker: pickerController, sessionId: () => imageSessionWorkflow.sessionId, imageLocation: () => imageSessionWorkflow.location, - mutationsAvailable: () => mutationWorkflow.objectRenameAvailable, - selectedSource: () => imageSessionWorkflow.selectedSource, + imageFormat: () => imageSessionWorkflow.imageFormat, + mutationsAvailable: () => mutationWorkflow.volumeAvailable, + selectedSource: () => imageSessionWorkflow.importDestinationSource(), setSelectedSource: (item) => imageSessionWorkflow.selectSource(item), sourceItems: () => imageSessionWorkflow.sourceItems, activeVolumeId: () => catalog.activeVolumeId, @@ -616,7 +617,6 @@ sampleNames={samples.map((item) => item.name)} sampleBankNames={sampleBanks.map((item) => item.name)} waveDataNames={waveData.map((item) => item.name)} - sequenceNames={sequences.map((item) => item.name)} /> {/await} {/if} diff --git a/apps/axkdeck/src/App.test.ts b/apps/axkdeck/src/App.test.ts index 536b3355..f6e160e1 100644 --- a/apps/axkdeck/src/App.test.ts +++ b/apps/axkdeck/src/App.test.ts @@ -1748,7 +1748,7 @@ describe('App panel layout', () => { delete runtime.__TAURI_INTERNALS__; }); - it('routes browser MIDI drops through the Sequence import review on the Sequences tab', async () => { + it('routes browser MIDI drops through the Sequence import review with the selected volume as a hint', async () => { const volume = { id: 'volume-1', name: 'My Volume', @@ -1765,8 +1765,7 @@ describe('App panel layout', () => { }); renderAcknowledgedApp(); await chooseNestedImage(); - await fireEvent.click(screen.getByRole('button', { name: 'Sequences' })); - + await screen.findByText('My Volume'); const dataTransfer = { types: ['Files'], files: [new File(['midi'], 'intro.MID', { type: 'audio/midi' })], @@ -1777,7 +1776,9 @@ describe('App panel layout', () => { window.dispatchEvent(drop); const dialog = await screen.findByRole('dialog', { name: 'Import MIDI' }); - expect(within(dialog).getByText('Volume My Volume')).toBeTruthy(); + expect((within(dialog).getByRole('combobox', { name: 'Destination volume' }) as HTMLInputElement).value).toBe( + 'My Volume', + ); expect(within(dialog).getByDisplayValue('intro')).toBeTruthy(); await vi.waitFor(() => expect(mocks.uploadClientFile).toHaveBeenCalledWith( @@ -1830,7 +1831,7 @@ describe('App panel layout', () => { delete runtime.__TAURI_INTERNALS__; }); - it('requires the Sequences tab for MIDI drops and rejects mixed media drops', async () => { + it('accepts MIDI drops from any workspace tab and still rejects mixed media drops', async () => { const volume = { id: 'volume-1', name: 'My Volume', @@ -1851,16 +1852,13 @@ describe('App panel layout', () => { const midiDrop = new Event('drop', { bubbles: true, cancelable: true }) as DragEvent; Object.defineProperty(midiDrop, 'dataTransfer', { value: midiTransfer }); window.dispatchEvent(midiDrop); - let unavailable = await screen.findByRole('dialog', { name: 'MIDI import unavailable' }); + const midiDialog = await screen.findByRole('dialog', { name: 'Import MIDI' }); expect( - within(unavailable).getByText( - 'Open the Sequences tab, select a writable volume, then drop the MIDI files again.', - ), - ).toBeTruthy(); - expect(screen.queryByRole('dialog', { name: 'Import MIDI' })).toBeNull(); - await fireEvent.click(within(unavailable).getByRole('button', { name: 'OK' })); + (within(midiDialog).getByRole('combobox', { name: 'Destination volume' }) as HTMLInputElement).value, + ).toBe('My Volume'); + await fireEvent.click(within(midiDialog).getByRole('button', { name: 'Cancel' })); + await vi.waitFor(() => expect(screen.queryByRole('dialog', { name: 'Import MIDI' })).toBeNull()); - await fireEvent.click(screen.getByRole('button', { name: 'Sequences' })); const mixedTransfer = { types: ['Files'], files: [ @@ -1872,7 +1870,7 @@ describe('App panel layout', () => { const mixedDrop = new Event('drop', { bubbles: true, cancelable: true }) as DragEvent; Object.defineProperty(mixedDrop, 'dataTransfer', { value: mixedTransfer }); window.dispatchEvent(mixedDrop); - unavailable = await screen.findByRole('dialog', { name: 'Import unavailable' }); + const unavailable = await screen.findByRole('dialog', { name: 'Import unavailable' }); expect( within(unavailable).getByText('Drop packages, A3K archives, audio, MIDI, and TX16W disks separately.'), ).toBeTruthy(); @@ -1916,7 +1914,9 @@ describe('App panel layout', () => { screen.getByText('Second Volume').dispatchEvent(drop); const dialog = await screen.findByRole('dialog', { name: 'Import MIDI' }); - expect(within(dialog).getByText('Volume First Volume')).toBeTruthy(); + expect((within(dialog).getByRole('combobox', { name: 'Destination volume' }) as HTMLInputElement).value).toBe( + 'First Volume', + ); expect(mocks.objectPage).not.toHaveBeenCalledWith(17, 0, 256, { scopeId: 'volume-2' }); }); diff --git a/apps/axkdeck/src/app.css b/apps/axkdeck/src/app.css index 60ac2419..145503d8 100644 --- a/apps/axkdeck/src/app.css +++ b/apps/axkdeck/src/app.css @@ -1332,6 +1332,10 @@ input:focus-visible { width: min(680px, calc(100vw - 40px)); } +.dialog-popovers-visible { + overflow: visible; +} + .dialog-header, .dialog-footer { display: flex; diff --git a/apps/axkdeck/src/features/dialogs/AppDialogs.svelte b/apps/axkdeck/src/features/dialogs/AppDialogs.svelte index e5d4df44..c15237f2 100644 --- a/apps/axkdeck/src/features/dialogs/AppDialogs.svelte +++ b/apps/axkdeck/src/features/dialogs/AppDialogs.svelte @@ -95,7 +95,6 @@ sampleNames: string[]; sampleBankNames: string[]; waveDataNames: string[]; - sequenceNames: string[]; } let { @@ -135,7 +134,6 @@ sampleNames, sampleBankNames, waveDataNames, - sequenceNames, }: Props = $props(); function directChoiceVisible(operation: DirectComputerOperation, contentAvailable: boolean): boolean { @@ -535,12 +533,23 @@ void sequenceImport.chooseWorkspace()} onchooselocal={transport.supportsClientUploads && sequenceFileInput ? () => sequenceImport.chooseLocal(sequenceFileInput) : undefined} + ondestinationmode={(mode) => sequenceImport.setDestinationMode(mode)} + ondestinationvolume={(partitionIndex, volumeName) => + void sequenceImport.setExistingVolume(partitionIndex, volumeName)} + ondestinationpartition={(partitionIndex) => sequenceImport.setDestinationPartition(partitionIndex)} + ondestinationname={(volumeName) => sequenceImport.setDestinationVolumeName(volumeName)} oncommit={(items, systemExclusivePolicy) => sequenceImport.commit(items, systemExclusivePolicy)} oncancel={() => (sequenceImport.request = null)} /> diff --git a/apps/axkdeck/src/features/import/audioWorkflow.svelte.ts b/apps/axkdeck/src/features/import/audioWorkflow.svelte.ts index 132c06ac..e3d37ae6 100644 --- a/apps/axkdeck/src/features/import/audioWorkflow.svelte.ts +++ b/apps/axkdeck/src/features/import/audioWorkflow.svelte.ts @@ -2,7 +2,7 @@ import { audioExtensions, audioMediaType } from '../../lib/audioImport'; import { browserUploadSource, type ClientUploadSource } from '../../lib/clientUploadSource'; import type { DirectoryRef, FileLocation, ImageLocation } from '../../lib/storageLocations'; import type { - AudioImportDestination, + VolumeImportDestination, AudioImportGrouping, AudioImportItem, AudioImportTarget, @@ -174,7 +174,7 @@ export class AudioImportWorkflow { if (target?.kind === 'EXISTING_VOLUME') await this.loadExistingVolume(target); } - destination(): AudioImportDestination | null { + destination(): VolumeImportDestination | null { const request = this.request; if (!request) return null; return importDestination( diff --git a/apps/axkdeck/src/features/import/mediaDropWorkflow.svelte.ts b/apps/axkdeck/src/features/import/mediaDropWorkflow.svelte.ts index c7588ae1..65a1e803 100644 --- a/apps/axkdeck/src/features/import/mediaDropWorkflow.svelte.ts +++ b/apps/axkdeck/src/features/import/mediaDropWorkflow.svelte.ts @@ -98,11 +98,6 @@ export class MediaDropWorkflow { const names = Array.from(dataTransfer.files).map((file) => file.name); const classified = classifyDroppedNames(names); const kind = classified === 'none' ? this.defaultKind() : classified; - if (kind === 'midi' && this.dependencies.workspaceView() !== 'sequences') { - dataTransfer.dropEffect = 'none'; - this.clearHover(); - return; - } this.dragKind = kind; this.dragTarget = kind === 'mixed' ? null : this.selectedVolumeTarget(); this.dragActive = true; @@ -142,9 +137,8 @@ export class MediaDropWorkflow { } const selectedKind = kind === 'none' ? this.defaultKind() : kind; if (kind === 'none') { - const volumeTarget = this.selectedVolumeTarget(); if (selectedKind === 'midi') - await this.dependencies.sequenceImport.requestDroppedFiles(files, volumeTarget); + await this.dependencies.sequenceImport.requestDroppedFiles(files, this.dependencies.selectedSource()); else await this.dependencies.audioImport.requestDroppedFiles(files, this.dependencies.selectedSource()); return; } @@ -181,23 +175,7 @@ export class MediaDropWorkflow { return; } if (selectedKind === 'midi') { - if (this.dependencies.workspaceView() !== 'sequences') { - this.dependencies.setStatus('Open the Sequences tab to import MIDI files'); - this.notice = { - title: 'MIDI import unavailable', - message: 'Open the Sequences tab, select a writable volume, then drop the MIDI files again.', - }; - return; - } - if (!volumeTarget) { - this.dependencies.setStatus('Select a writable volume first'); - this.notice = { - title: 'MIDI import unavailable', - message: 'Select a writable volume in Contents, then drop the MIDI files again.', - }; - return; - } - await this.dependencies.sequenceImport.requestDroppedFiles(files, volumeTarget); + await this.dependencies.sequenceImport.requestDroppedFiles(files, this.dependencies.selectedSource()); return; } await this.dependencies.audioImport.requestDroppedFiles(files, this.dependencies.selectedSource()); @@ -209,7 +187,7 @@ export class MediaDropWorkflow { return; } const kind = classifyDroppedNames(paths); - if (kind === 'none' || (kind === 'midi' && this.dependencies.workspaceView() !== 'sequences')) { + if (kind === 'none') { this.clearHover(); return; } diff --git a/apps/axkdeck/src/features/import/packageDestinations.ts b/apps/axkdeck/src/features/import/packageDestinations.ts index 21ee731e..c6c609ec 100644 --- a/apps/axkdeck/src/features/import/packageDestinations.ts +++ b/apps/axkdeck/src/features/import/packageDestinations.ts @@ -1,5 +1,5 @@ import type { DiskTreeItem } from '../../lib/types'; -import type { PackageInspection } from '../../lib/transport'; +import type { PackageInspection, VolumeImportDestination } from '../../lib/transport'; export type ImportDestinationMode = 'existing' | 'create'; @@ -20,10 +20,6 @@ export interface ImportDestinations { volumeItems: DiskTreeItem[]; } -export type ImportDestination = - | { kind: 'EXISTING_VOLUME'; partitionIndex: number; volumeName: string } - | { kind: 'CREATE_VOLUME'; partitionIndex: number; volumeName: string }; - export function collectImportDestinations(sourceItems: readonly DiskTreeItem[]): ImportDestinations { const partitionItems: DiskTreeItem[] = []; const volumeItems: DiskTreeItem[] = []; @@ -69,7 +65,7 @@ export function importDestination( mode: ImportDestinationMode, partitionIndex: number | null, rawVolumeName: string, -): ImportDestination | null { +): VolumeImportDestination | null { if (partitionIndex === null || rawVolumeName.length === 0) return null; if ( mode === 'create' && diff --git a/apps/axkdeck/src/features/import/sequenceWorkflow.svelte.ts b/apps/axkdeck/src/features/import/sequenceWorkflow.svelte.ts index 114da486..16cbee10 100644 --- a/apps/axkdeck/src/features/import/sequenceWorkflow.svelte.ts +++ b/apps/axkdeck/src/features/import/sequenceWorkflow.svelte.ts @@ -4,18 +4,26 @@ import type { DirectoryRef, FileLocation, ImageLocation } from '../../lib/storag import type { ImageTransport, SequenceImportItem, - SequenceImportTarget, SequenceSystemExclusivePolicy, + VolumeImportDestination, } from '../../lib/transport'; import type { DiskTreeItem, SequenceItem, WorkspaceView } from '../../lib/types'; import { userFacingMessage } from '../../lib/userFacingMessage'; import type { PickerController } from '../dialogs/picker'; import type { JobController } from '../jobs/actions'; +import { + collectImportDestinations, + importDestination, + initialImportDestination, + type ImportDestinationMode, +} from './packageDestinations'; import { findVolumeSourceItem, sameVolumeTarget } from './volumeTarget'; export interface SequenceImportRequest { files: (ClientUploadSource | FileLocation)[]; - target: SequenceImportTarget; + destinationMode: ImportDestinationMode; + destinationPartitionIndex: number | null; + destinationVolumeName: string; } interface SequenceImportDependencies { @@ -24,6 +32,7 @@ interface SequenceImportDependencies { picker: PickerController; sessionId: () => number | null; imageLocation: () => ImageLocation | null; + imageFormat: () => string | null; mutationsAvailable: () => boolean; selectedSource: () => DiskTreeItem; setSelectedSource: (item: DiskTreeItem) => void; @@ -41,37 +50,44 @@ interface SequenceImportDependencies { export class SequenceImportWorkflow { request = $state(null); + destinationBusy = $state(false); private lastDirectory = $state(null); + private destinationRevision = 0; constructor(private readonly dependencies: SequenceImportDependencies) {} dropAvailable(): boolean { - return this.dependencies.mutationsAvailable() && this.dependencies.imageLocation() !== null; + return ( + this.dependencies.mutationsAvailable() && + this.dependencies.imageLocation() !== null && + this.dependencies.imageFormat() === 'sfs' + ); } - activeTarget(): SequenceImportTarget | null { + activeTarget(): VolumeImportDestination | null { const selected = this.dependencies.selectedSource(); return this.dependencies.mutationsAvailable() && selected.kind === 'volume' && selected.partitionIndex !== undefined - ? { partitionIndex: selected.partitionIndex, volumeName: selected.name } + ? { kind: 'EXISTING_VOLUME', partitionIndex: selected.partitionIndex, volumeName: selected.name } : null; } chooseFiles(): void { - const target = this.activeTarget(); - if (!target || !this.dependencies.imageLocation()) { - this.dependencies.setStatus('Select a writable volume first'); + if (!this.dropAvailable()) { + this.dependencies.setStatus('Open a writable SFS hard-disk image first'); return; } - this.request = { files: [], target }; + this.request = this.newRequest([], this.dependencies.selectedSource()); } filesChosen(event: Event): void { const input = event.currentTarget as HTMLInputElement; + const request = this.request; void this.requestDroppedFiles( Array.from(input.files ?? []).map(browserUploadSource), - this.request?.target ?? this.activeTarget(), + null, + request ?? undefined, ); input.value = ''; } @@ -85,7 +101,7 @@ export class SequenceImportWorkflow { ondirectorychange: (directory) => (this.lastDirectory = directory), }); if (!selections || !this.request) return; - await this.requestDroppedFiles(selections, request.target); + await this.requestDroppedFiles(selections, null, request); } chooseLocal(input: HTMLInputElement): void { @@ -96,8 +112,11 @@ export class SequenceImportWorkflow { async commit(items: SequenceImportItem[], systemExclusivePolicy: SequenceSystemExclusivePolicy): Promise { const request = this.request; const sessionId = this.dependencies.sessionId(); - if (!request || sessionId === null) throw new Error('MIDI import target is no longer available'); - const target = request.target; + if (!request || sessionId === null || this.destinationBusy) { + throw new Error('MIDI import target is no longer available'); + } + const target = this.destination(); + if (!target) throw new Error('Choose a valid MIDI import destination'); const firstName = items[0]?.sequenceName; const started = performance.now(); this.dependencies.setStatus('Importing MIDI'); @@ -111,7 +130,10 @@ export class SequenceImportWorkflow { ); if (completed.status !== 'completed') throw new Error(completed.error ?? 'MIDI import did not complete'); this.dependencies.selectWorkspace('sequences'); - await this.dependencies.refreshSession(target); + await this.dependencies.refreshSession({ + partitionIndex: target.partitionIndex, + volumeName: target.volumeName, + }); const inserted = this.dependencies.sequences().find((sequence) => sequence.name === firstName); if (inserted) this.dependencies.selectSequence(inserted); this.dependencies.reportTiming('midi-import', started, items.length); @@ -123,7 +145,8 @@ export class SequenceImportWorkflow { async requestDroppedFiles( files: (ClientUploadSource | FileLocation)[], - target = this.activeTarget(), + selected: DiskTreeItem | null = this.dependencies.selectedSource(), + destinationRequest?: SequenceImportRequest, ): Promise { const admitted = files .map((source) => { @@ -136,24 +159,128 @@ export class SequenceImportWorkflow { this.dependencies.setStatus('No Standard MIDI Files were selected'); return; } - if (!target || admitted.length === 0 || !this.dependencies.imageLocation()) { - this.dependencies.setStatus(target ? 'Choose MIDI files' : 'Select a writable volume first'); + if (admitted.length === 0 || !this.dropAvailable()) { + this.dependencies.setStatus('Drop Standard MIDI Files onto a writable SFS hard-disk image'); return; } - if (!sameVolumeTarget(this.activeTarget(), target)) { - const item = findVolumeSourceItem(this.dependencies.sourceItems(), target); - if (!item) { - this.dependencies.setStatus('MIDI import target is no longer available'); - return; - } - this.dependencies.setSelectedSource(item); - await this.dependencies.loadVolume(item.id); - if (this.dependencies.activeVolumeId() !== item.id) { - this.dependencies.setStatus('MIDI import target is no longer available'); - return; + this.request = destinationRequest + ? { ...destinationRequest, files: admitted } + : this.newRequest(admitted, selected); + const target = this.destination(); + if (target?.kind === 'EXISTING_VOLUME') await this.synchronizeExistingVolume(target); + } + + destination(): VolumeImportDestination | null { + const request = this.request; + if (!request) return null; + return importDestination( + request.destinationMode, + request.destinationPartitionIndex, + request.destinationVolumeName, + ); + } + + partitionOptions() { + return collectImportDestinations(this.dependencies.sourceItems()).partitions; + } + + volumeOptions() { + return collectImportDestinations(this.dependencies.sourceItems()).volumes; + } + + existingSequenceNames(): string[] { + if (this.destinationBusy) return []; + const target = this.destination(); + if (target?.kind !== 'EXISTING_VOLUME') return []; + const item = findVolumeSourceItem(this.dependencies.sourceItems(), target); + if (!item || item.id !== this.dependencies.activeVolumeId()) return []; + return this.dependencies.sequences().map((sequence) => sequence.name); + } + + setDestinationMode(mode: ImportDestinationMode): void { + const request = this.request; + if (!request) return; + this.cancelDestinationSynchronization(); + const destinations = collectImportDestinations(this.dependencies.sourceItems()); + request.destinationMode = mode; + request.destinationVolumeName = ''; + request.destinationPartitionIndex = + request.destinationPartitionIndex ?? destinations.partitions[0]?.partitionIndex ?? null; + } + + async setExistingVolume(partitionIndex: number | null, volumeName: string): Promise { + const request = this.request; + if (!request) return; + request.destinationPartitionIndex = partitionIndex; + request.destinationVolumeName = volumeName; + if (partitionIndex === null || !volumeName) { + this.cancelDestinationSynchronization(); + return; + } + await this.synchronizeExistingVolume({ + kind: 'EXISTING_VOLUME', + partitionIndex, + volumeName, + }); + } + + setDestinationPartition(partitionIndex: number): void { + if (!this.request) return; + this.cancelDestinationSynchronization(); + this.request.destinationPartitionIndex = partitionIndex; + if (this.request.destinationMode === 'existing') this.request.destinationVolumeName = ''; + } + + setDestinationVolumeName(volumeName: string): void { + if (this.request) this.request.destinationVolumeName = volumeName.slice(0, 16); + } + + private newRequest( + files: (ClientUploadSource | FileLocation)[], + selected: DiskTreeItem | null, + ): SequenceImportRequest { + const initial = selected ? initialImportDestination(selected) : null; + const firstPartition = collectImportDestinations(this.dependencies.sourceItems()).partitions[0]; + return { + files, + destinationMode: initial?.mode ?? 'existing', + destinationPartitionIndex: initial?.partitionIndex ?? firstPartition?.partitionIndex ?? null, + destinationVolumeName: initial?.volumeName ?? '', + }; + } + + private async synchronizeExistingVolume(target: VolumeImportDestination): Promise { + if (target.kind !== 'EXISTING_VOLUME') return; + const revision = ++this.destinationRevision; + this.destinationBusy = true; + try { + await this.loadExistingVolume(target); + } catch (error) { + if (revision === this.destinationRevision && this.request) { + this.request.destinationVolumeName = ''; + this.dependencies.setStatus(userFacingMessage(error)); } + } finally { + if (revision === this.destinationRevision) this.destinationBusy = false; + } + } + + private cancelDestinationSynchronization(): void { + this.destinationRevision += 1; + this.destinationBusy = false; + } + + private async loadExistingVolume(target: VolumeImportDestination): Promise { + if (target.kind !== 'EXISTING_VOLUME') return; + if (sameVolumeTarget(this.activeTarget(), target)) return; + const item = findVolumeSourceItem(this.dependencies.sourceItems(), target); + if (!item) throw new Error('MIDI import target is no longer available'); + if (this.dependencies.activeVolumeId() === item.id) return; + this.dependencies.setSelectedSource(item); + await this.dependencies.loadVolume(item.id); + if (this.dependencies.activeVolumeId() !== item.id) { + throw new Error('MIDI import target is no longer available'); } - this.request = { files: admitted, target }; } } diff --git a/apps/axkdeck/src/features/workspace/WorkspaceShell.svelte b/apps/axkdeck/src/features/workspace/WorkspaceShell.svelte index e78fa565..7af053cb 100644 --- a/apps/axkdeck/src/features/workspace/WorkspaceShell.svelte +++ b/apps/axkdeck/src/features/workspace/WorkspaceShell.svelte @@ -394,7 +394,7 @@ class:has-audition-bar={auditionAvailable} class="main-stage" style:--split-position={`${splitRatio * 100}%`} - data-import-drop-main={audioImport.activeTarget() || sequenceImport.activeTarget() ? 'true' : undefined} + data-import-drop-main={audioImport.dropAvailable() || sequenceImport.dropAvailable() ? 'true' : undefined} > {#if workspaceView === 'sample-banks' || workspaceView === 'samples'} void; onchooselocal?: () => void; + ondestinationmode: (mode: ImportDestinationMode) => void; + ondestinationvolume: (partitionIndex: number | null, volumeName: string) => void; + ondestinationpartition: (partitionIndex: number) => void; + ondestinationname: (volumeName: string) => void; oncommit: (items: SequenceImportItem[], systemExclusivePolicy: SequenceSystemExclusivePolicy) => Promise; oncancel: () => void; } @@ -43,9 +59,19 @@ transport, files, target, + destinationMode, + destinationPartitionIndex, + destinationVolumeName, + partitionOptions, + volumeOptions, + destinationBusy, existingSequenceNames, onchooseworkspace, onchooselocal, + ondestinationmode, + ondestinationvolume, + ondestinationpartition, + ondestinationname, oncommit, oncancel, }: Props = $props(); @@ -60,6 +86,8 @@ const validationErrors = $derived(validateRows(rows, existingSequenceNames)); const ready = $derived( rows.length > 0 && + target !== null && + !destinationBusy && rows.every((row) => row.status === 'ready' && row.source !== undefined && row.inspection !== undefined) && validationErrors.every((error) => error === ''), ); @@ -88,7 +116,8 @@ } $effect(() => { - const usedNames = new Set(existingSequenceNames.map((name) => name.toLocaleLowerCase())); + const initialExistingNames = untrack(() => existingSequenceNames); + const usedNames = new Set(initialExistingNames.map((name) => name.toLocaleLowerCase())); rows = files.map((input) => { const candidate = 'kind' in input ? input : 'readChunk' in input ? input : browserUploadSource(input); const fileName = sourceName(candidate); @@ -222,27 +251,38 @@