diff --git a/.gitignore b/.gitignore index 9426e0c..f1f53f1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,11 @@ gen/ # Logs *.log +# App State +desktop-state.json +desktop-config.json +rustatio.toml + # OS .DS_Store Thumbs.db diff --git a/ui/src/App.svelte b/ui/src/App.svelte index a2ea4c2..81f1fe7 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -240,7 +240,10 @@ return; } - saveSession(insts, activeInst.id); + saveSession( + insts.filter(i => i.id !== 'bulk-edit'), + activeInst.id + ); }, 500); } }); @@ -1145,7 +1148,9 @@ // Start all instances with torrents loaded (bulk) async function startAllInstances() { const currentInstances = get(instances); - const instancesToStart = currentInstances.filter(inst => inst.torrent && !inst.isRunning); + const instancesToStart = currentInstances.filter( + inst => inst.id !== 'bulk-edit' && inst.torrent && !inst.isRunning + ); if (instancesToStart.length === 0) return; @@ -1517,11 +1522,11 @@ statusIcon={$activeInstance?.statusIcon || null} isRunning={$activeInstance?.isRunning || false} isPaused={$activeInstance?.isPaused || false} - {startFaking} - {stopFaking} - {pauseFaking} - {resumeFaking} - {manualUpdate} + startFaking={$activeInstance?.torrent?.isBulk ? null : startFaking} + stopFaking={$activeInstance?.torrent?.isBulk ? null : stopFaking} + pauseFaking={$activeInstance?.torrent?.isBulk ? null : pauseFaking} + resumeFaking={$activeInstance?.torrent?.isBulk ? null : resumeFaking} + manualUpdate={$activeInstance?.torrent?.isBulk ? null : manualUpdate} /> {/if} diff --git a/ui/src/components/common/TorrentSelector.svelte b/ui/src/components/common/TorrentSelector.svelte index 9d620c9..0aadd9d 100644 --- a/ui/src/components/common/TorrentSelector.svelte +++ b/ui/src/components/common/TorrentSelector.svelte @@ -11,7 +11,9 @@ ChevronDown, ChevronRight, Upload, + X, } from '@lucide/svelte'; + import { instanceActions } from '$lib/instanceStore.js'; let { torrent, selectTorrent, formatBytes } = $props(); @@ -94,7 +96,8 @@

- Torrent File + + {torrent?.isBulk ? 'Torrent Files' : 'Torrent File'}

{torrent.name} -
- {formatBytes(torrent.total_size)} - - - {torrent.file_count || torrent.files?.length || 1} file{(torrent.file_count || - torrent.files?.length || - 1) > 1 - ? 's' - : ''} - - - {trackers.length} tracker{trackers.length !== 1 ? 's' : ''} -
+ {#if !torrent.isBulk} +
+ {formatBytes(torrent.total_size)} + + + {torrent.file_count || torrent.files?.length || 1} file{(torrent.file_count || + torrent.files?.length || + 1) > 1 + ? 's' + : ''} + + + {trackers.length} tracker{trackers.length !== 1 ? 's' : ''} +
+ {/if} - + {#if !torrent.isBulk} + + {/if} - -
-
-
Size
-
{formatBytes(torrent.total_size)}
-
-
-
Pieces
-
{torrent.num_pieces?.toLocaleString() || 'N/A'}
-
-
-
Piece Size
-
- {torrent.piece_length ? formatBytes(torrent.piece_length) : 'N/A'} + {#if !torrent.isBulk} + +
+
+
Size
+
{formatBytes(torrent.total_size)}
-
-
-
Files
-
- {torrent.file_count || torrent.files?.length || 1} +
+
Pieces
+
{torrent.num_pieces?.toLocaleString() || 'N/A'}
+
+
+
Piece Size
+
+ {torrent.piece_length ? formatBytes(torrent.piece_length) : 'N/A'} +
+
+
+
Files
+
+ {torrent.file_count || torrent.files?.length || 1} +
-
+ {/if}
{/each}
- {/if} - - - {#if torrent.files && torrent.files.length > 0} + {:else} + +
- Files ({torrent.files.length}) + Info Hash
- {#if torrent.files.length <= 10} -
- {#each torrent.files as file (file.path)} -
- - {file.path?.join('/') || 'Unknown'} - - - {formatBytes(file.length)} - + + {torrent.info_hash + ? Array.from(torrent.info_hash) + .map(b => b.toString(16).padStart(2, '0')) + .join('') + : 'N/A'} + +
+ + + {#if trackers.length > 0} +
+
+ Trackers ({trackers.length}) +
+
+ {#each trackers as tracker, index (tracker)} +
+ {#if index === 0} + + Primary + + {:else} + #{index + 1} + {/if} + + {tracker} +
{/each}
- {:else} -
- {torrent.files.length} files (too many to display) +
+ {/if} + + + {#if torrent.files && torrent.files.length > 0} +
+
+ Files ({torrent.files.length})
- {/if} -
+ {#if torrent.files.length <= 10} +
+ {#each torrent.files as file (file.path)} +
+ + {file.path?.join('/') || 'Unknown'} + + + {formatBytes(file.length)} + +
+ {/each} +
+ {:else} +
+ {torrent.files.length} files (too many to display) +
+ {/if} +
+ {/if} {/if} +
{/if}
diff --git a/ui/src/components/grid/GridView.svelte b/ui/src/components/grid/GridView.svelte index 6f22e63..7d46779 100644 --- a/ui/src/components/grid/GridView.svelte +++ b/ui/src/components/grid/GridView.svelte @@ -42,6 +42,12 @@ await gridActions.resumeInstance(instance.id); break; case 'edit': { + const selected = gridActions.getSelectedIds(); + if (selected.length > 1 && selected.includes(instance.id)) { + instanceActions.createBulkInstance(selected); + viewMode.set('standard'); + break; + } const ensuredId = await instanceActions.ensureInstance(instance.id, instance); if (ensuredId) { instanceActions.selectInstance(ensuredId); diff --git a/ui/src/components/layout/Sidebar.svelte b/ui/src/components/layout/Sidebar.svelte index d769bcc..af96b9a 100644 --- a/ui/src/components/layout/Sidebar.svelte +++ b/ui/src/components/layout/Sidebar.svelte @@ -54,13 +54,15 @@ // Derived state let hasMultipleInstancesWithTorrents = $derived( - $instances.filter(inst => inst.torrent).length > 1 + $instances.filter(inst => inst.id !== 'bulk-edit' && inst.torrent).length > 1 ); - let hasRunningInstances = $derived($instances.some(inst => inst.isRunning)); + let hasRunningInstances = $derived( + $instances.some(inst => inst.id !== 'bulk-edit' && inst.isRunning) + ); let hasStoppedInstancesWithTorrents = $derived( - $instances.some(inst => inst.torrent && !inst.isRunning) + $instances.some(inst => inst.id !== 'bulk-edit' && inst.torrent && !inst.isRunning) ); let hasPausedInstances = $derived($instances.some(inst => inst.isRunning && inst.isPaused)); diff --git a/ui/src/lib/instanceStore.js b/ui/src/lib/instanceStore.js index d86426c..6664eaa 100644 --- a/ui/src/lib/instanceStore.js +++ b/ui/src/lib/instanceStore.js @@ -8,6 +8,14 @@ const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window // Helper to convert bytes to MB (rounded to integer) const bytesToMB = bytes => Math.round((bytes || 0) / (1024 * 1024)); +// Helper to resolve display names for a list of instance IDs +function _getBulkInstanceNames(ids, instances) { + return ids.map(id => { + const i = instances.find(inst => inst.id === id); + return i ? i.name || i.torrent?.name || i.torrentPath || 'Unknown Torrent' : 'Unknown Torrent'; + }); +} + // Compute the effective (randomized) stop ratio export function computeEffectiveRatio(stopAtRatio, randomizeRatio, randomRatioRangePercent) { if (!randomizeRatio) { @@ -709,6 +717,44 @@ export const instanceActions = { // Update a specific instance updateInstance: (id, updates) => { + // Intercept bulk-edit updates + if (id === 'bulk-edit') { + const currentInstances = get(instances); + const bulkInst = currentInstances.find(i => i.id === 'bulk-edit'); + if (!bulkInst) return; + + // Ensure we don't overwrite bulk-edit internal fields (like targetIds or torrent) + const safeUpdates = { ...updates }; + delete safeUpdates.id; + delete safeUpdates.targetIds; + delete safeUpdates.torrent; + delete safeUpdates.torrentPath; + delete safeUpdates.stats; + + instances.update(insts => { + return insts.map(inst => { + if (inst.id === 'bulk-edit') { + return { ...inst, ...safeUpdates }; + } + if (bulkInst.targetIds && bulkInst.targetIds.includes(inst.id)) { + return { ...inst, ...safeUpdates }; + } + return inst; + }); + }); + + if (id === get(activeInstanceId)) { + updateActiveInstanceStore(); + } + + // Save session since we probably modified real instances + saveSession( + get(instances).filter(i => i.id !== 'bulk-edit'), + get(activeInstanceId) + ); + return; + } + // Don't update if no changes const currentInstances = get(instances); const currentInst = currentInstances.find(i => i.id === id); @@ -759,6 +805,96 @@ export const instanceActions = { return get(activeInstance); }, + // Create or update a temporary bulk edit instance + createBulkInstance: targetIds => { + if (!targetIds || targetIds.length === 0) return; + + const currentInstances = get(instances); + + // Filter out 'bulk-edit' just in case it's in the list + const validTargetIds = targetIds.filter(id => id !== 'bulk-edit'); + if (validTargetIds.length === 0) return; + + const firstTarget = currentInstances.find(i => i.id === validTargetIds[0]); + if (!firstTarget) return; + + // Create a new instance with 'bulk-edit' ID + // Clone properties from the first selected instance to use as defaults + const bulkInstance = createDefaultInstance('bulk-edit', firstTarget); + + // Overwrite fields specific to bulk edit + bulkInstance.targetIds = validTargetIds; + bulkInstance.torrent = { + name: `Multiple Torrents (${validTargetIds.length})`, + isBulk: true, + bulkIds: validTargetIds, + // Pass along the names for the UI to display + bulkNames: _getBulkInstanceNames(validTargetIds, currentInstances), + }; + bulkInstance.torrentPath = 'Multiple Torrents'; + bulkInstance.statusMessage = 'Editing multiple instances'; + bulkInstance.statusType = 'idle'; + bulkInstance.isRunning = false; + bulkInstance.isPaused = false; + bulkInstance.stats = null; + + instances.update(insts => { + // Remove any existing bulk-edit instance + const filtered = insts.filter(inst => inst.id !== 'bulk-edit'); + return [...filtered, bulkInstance]; + }); + + // Switch view to it + activeInstanceId.set('bulk-edit'); + updateActiveInstanceStore(); + }, + + // Remove an instance from the current bulk edit selection + removeTargetInstance: targetId => { + const bulkId = get(activeInstanceId); + if (bulkId !== 'bulk-edit') return; // Not in bulk edit mode + + instances.update(insts => { + const bulkInst = insts.find(i => i.id === 'bulk-edit'); + if (!bulkInst || !bulkInst.targetIds) return insts; + + const updatedTargetIds = bulkInst.targetIds.filter(id => id !== targetId); + + // If less than 2 items left, bulk edit doesn't make sense, just exit back to grid + if (updatedTargetIds.length < 2) { + // We defer this view change slightly to let the store update finish + import('./gridStore.js').then(module => { + module.viewMode.set('grid'); + module.gridActions.deselectAll(); + }); + + return insts.filter(i => i.id !== 'bulk-edit'); + } + + // Update the bulk-edit instance + return insts.map(inst => { + if (inst.id === 'bulk-edit') { + // Re-generate names list + const remainingNames = _getBulkInstanceNames(updatedTargetIds, insts); + + return { + ...inst, + targetIds: updatedTargetIds, + torrent: { + ...inst.torrent, + name: `Multiple Torrents (${updatedTargetIds.length})`, + bulkIds: updatedTargetIds, + bulkNames: remainingNames, + }, + }; + } + return inst; + }); + }); + + updateActiveInstanceStore(); + }, + // Merge a new instance from server (used for real-time sync with watch folder) // Returns true if a new instance was added, false if it already existed mergeServerInstance: serverInst => { @@ -826,6 +962,9 @@ export const instanceActions = { instances.update(current => { const filtered = current.filter(inst => { + // Never prune the temporary bulk-edit instance + if (inst.id === 'bulk-edit') return true; + if (backendIds.has(String(inst.id))) return true; const hasLoadedTorrent = Boolean(inst.torrent) || Boolean(inst.torrentPath);