feat(device): add device sync button

This commit is contained in:
2025-10-15 11:45:52 -04:00
parent af4f8ce77f
commit 8d773f8188
8 changed files with 1052 additions and 2 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -26,6 +26,8 @@ dependencies = [
"tauri-plugin-sql",
"tauri-plugin-store",
"tokio",
"unicode-normalization",
"walkdir",
]
[[package]]

View File

@@ -37,4 +37,6 @@ reqwest = { version = "0.12.23", features = ["stream", "rustls-tls"] }
tokio = { version = "1.47.1", features = ["fs", "io-util"] }
futures-util = "0.3.31"
tauri-plugin-os = "2"
walkdir = "2.5.0"
unicode-normalization = "0.1.24"

View File

@@ -0,0 +1,241 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Emitter};
use unicode_normalization::UnicodeNormalization;
use walkdir::WalkDir;
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileInfo {
relative_path: String,
size: u64,
status: String, // "new" | "updated"
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncDiff {
files_to_copy: Vec<FileInfo>,
stats: SyncStats,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncStats {
new_files: usize,
updated_files: usize,
unchanged_files: usize,
total_size: u64,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncProgress {
current: usize,
total: usize,
current_file: String,
status: String,
}
struct FileMetadata {
size: u64,
}
fn should_skip_file(path: &Path) -> bool {
let file_name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// Skip system files
matches!(file_name, ".DS_Store" | "Thumbs.db" | "desktop.ini" | ".nomedia")
}
fn should_skip_dir(path: &Path) -> bool {
let dir_name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// Skip temp folders
dir_name == "_temp"
}
/// Normalize a path string to NFC form for consistent comparison
fn normalize_path(path: &Path) -> String {
path.to_string_lossy().nfc().collect::<String>()
}
/// Index device and compare with library
#[tauri::command]
pub async fn index_and_compare(
library_path: String,
device_path: String,
overwrite_mode: String, // "skip" | "different" | "always"
) -> Result<SyncDiff, String> {
let library_path = PathBuf::from(library_path);
let device_path = PathBuf::from(device_path);
// Validate paths exist
if !library_path.exists() {
return Err(format!("Library path does not exist: {}", library_path.display()));
}
if !device_path.exists() {
return Err(format!("Device path does not exist: {}", device_path.display()));
}
// Step 1: Index device - build HashMap of existing files with normalized paths
let mut device_files: HashMap<String, FileMetadata> = HashMap::new();
for entry in WalkDir::new(&device_path)
.follow_links(false)
.into_iter()
.filter_entry(|e| !should_skip_dir(e.path()))
{
let entry = entry.map_err(|e| format!("Error reading device: {}", e))?;
if entry.file_type().is_file() && !should_skip_file(entry.path()) {
let relative_path = entry.path()
.strip_prefix(&device_path)
.map_err(|e| format!("Path error: {}", e))?
.to_path_buf();
if let Ok(metadata) = entry.metadata() {
let normalized = normalize_path(&relative_path);
device_files.insert(normalized, FileMetadata {
size: metadata.len(),
});
}
}
}
// Step 2: Walk library and compare
let mut files_to_copy = Vec::new();
let mut new_count = 0;
let mut updated_count = 0;
let mut unchanged_count = 0;
let mut total_size = 0u64;
for entry in WalkDir::new(&library_path)
.follow_links(false)
.into_iter()
.filter_entry(|e| !should_skip_dir(e.path()))
{
let entry = entry.map_err(|e| format!("Error reading library: {}", e))?;
if entry.file_type().is_file() && !should_skip_file(entry.path()) {
let relative_path = entry.path()
.strip_prefix(&library_path)
.map_err(|e| format!("Path error: {}", e))?
.to_path_buf();
let metadata = entry.metadata()
.map_err(|e| format!("Cannot read file metadata: {}", e))?;
let file_size = metadata.len();
let normalized_path = normalize_path(&relative_path);
// Check if file exists on device (using normalized path)
if let Some(device_meta) = device_files.get(&normalized_path) {
// File exists on device
let size_different = device_meta.size != file_size;
let should_copy = match overwrite_mode.as_str() {
"skip" => false, // Never overwrite
"different" => size_different, // Only if different size
"always" => true, // Always overwrite
_ => size_different, // Default to "different"
};
if should_copy {
files_to_copy.push(FileInfo {
relative_path: relative_path.to_string_lossy().to_string(),
size: file_size,
status: "updated".to_string(),
});
updated_count += 1;
total_size += file_size;
} else {
unchanged_count += 1;
}
} else {
// File doesn't exist on device - new file
files_to_copy.push(FileInfo {
relative_path: relative_path.to_string_lossy().to_string(),
size: file_size,
status: "new".to_string(),
});
new_count += 1;
total_size += file_size;
}
}
}
Ok(SyncDiff {
files_to_copy,
stats: SyncStats {
new_files: new_count,
updated_files: updated_count,
unchanged_files: unchanged_count,
total_size,
},
})
}
/// Sync files to device
#[tauri::command]
pub async fn sync_to_device(
app: AppHandle,
library_path: String,
device_path: String,
files_to_copy: Vec<FileInfo>,
) -> Result<String, String> {
let library_path = PathBuf::from(library_path);
let device_path = PathBuf::from(device_path);
let total = files_to_copy.len();
for (index, file_info) in files_to_copy.iter().enumerate() {
let source = library_path.join(&file_info.relative_path);
let dest = device_path.join(&file_info.relative_path);
// Emit progress
app.emit("sync-progress", SyncProgress {
current: index + 1,
total,
current_file: file_info.relative_path.clone(),
status: format!("Copying {} ({} of {})", file_info.relative_path, index + 1, total),
}).map_err(|e| format!("Failed to emit progress: {}", e))?;
// Create parent directory if needed
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
// Copy file
fs::copy(&source, &dest)
.map_err(|e| {
// Check for common errors
match e.kind() {
std::io::ErrorKind::PermissionDenied => {
format!("Permission denied: {}", dest.display())
}
std::io::ErrorKind::NotFound => {
format!("Device disconnected or path not found: {}", dest.display())
}
_ => format!("Failed to copy {}: {}", file_info.relative_path, e)
}
})?;
}
// Emit completion
app.emit("sync-progress", SyncProgress {
current: total,
total,
current_file: String::new(),
status: format!("Sync complete! Copied {} files", total),
}).map_err(|e| format!("Failed to emit completion: {}", e))?;
Ok(format!("Successfully synced {} files to device", total))
}

View File

@@ -1,6 +1,7 @@
use tauri_plugin_sql::{Migration, MigrationKind};
mod deezer_crypto;
mod device_sync;
mod metadata;
mod tagger;
@@ -317,7 +318,9 @@ pub fn run() {
tag_audio_file,
read_audio_metadata,
decrypt_deezer_track,
download_and_decrypt_track
download_and_decrypt_track,
device_sync::index_and_compare,
device_sync::sync_to_device
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -9,6 +9,7 @@
play: '/icons/speaker.png',
search: '/icons/internet.png',
computer: '/icons/computer.png',
device: '/icons/ipod.svg',
};
let history: string[] = $state([]);
@@ -81,6 +82,11 @@
<span>Search</span>
</a>
<a href="/sync" class="toolbar-button" title="Device Sync">
<img src={icons.device} alt="Device Sync" />
<span>Sync</span>
</a>
<a href="/settings" class="toolbar-button" title="Settings">
<img src={icons.computer} alt="Settings" />
<span>Settings</span>

View File

@@ -0,0 +1,107 @@
/**
* Device sync service layer
* Handles communication with Tauri backend for device synchronization
*/
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
export interface FileInfo {
relativePath: string;
size: number;
status: 'new' | 'updated';
}
export interface SyncStats {
newFiles: number;
updatedFiles: number;
unchangedFiles: number;
totalSize: number;
}
export interface SyncDiff {
filesToCopy: FileInfo[];
stats: SyncStats;
}
export interface SyncProgress {
current: number;
total: number;
currentFile: string;
status: string;
}
export type ProgressCallback = (progress: SyncProgress) => void;
/**
* Index device and compare with library
* Returns a diff showing which files need to be synced
*/
export async function indexAndCompare(
libraryPath: string,
devicePath: string,
overwriteMode: string
): Promise<SyncDiff> {
try {
const result = await invoke<SyncDiff>('index_and_compare', {
libraryPath,
devicePath,
overwriteMode
});
return result;
} catch (error) {
console.error('Error indexing and comparing:', error);
throw new Error(String(error));
}
}
/**
* Sync files to device with progress updates
*/
export async function syncToDevice(
libraryPath: string,
devicePath: string,
filesToCopy: FileInfo[],
onProgress?: ProgressCallback
): Promise<string> {
let unlisten: UnlistenFn | null = null;
try {
// Set up progress listener
unlisten = await listen<SyncProgress>('sync-progress', (event) => {
if (onProgress) {
onProgress(event.payload);
}
});
// Start sync operation
const result = await invoke<string>('sync_to_device', {
libraryPath,
devicePath,
filesToCopy
});
return result;
} catch (error) {
console.error('Error syncing to device:', error);
throw new Error(String(error));
} finally {
// Clean up event listener
if (unlisten) {
unlisten();
}
}
}
/**
* Format bytes to human-readable string
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}

View File

@@ -0,0 +1,81 @@
import { LazyStore } from '@tauri-apps/plugin-store';
import { writable, type Writable } from 'svelte/store';
// Device sync settings interface
export type OverwriteMode = 'skip' | 'different' | 'always';
export interface DeviceSyncSettings {
musicPath: string | null;
playlistsPath: string | null;
overwriteMode: OverwriteMode;
}
// Initialize the store with device-sync.json
const store = new LazyStore('device-sync.json');
// Default settings
const defaultSettings: DeviceSyncSettings = {
musicPath: null,
playlistsPath: null,
overwriteMode: 'different'
};
// Create a writable store for reactive UI updates
export const deviceSyncSettings: Writable<DeviceSyncSettings> = writable(defaultSettings);
// Load settings from store
export async function loadDeviceSyncSettings(): Promise<void> {
const musicPath = await store.get<string>('musicPath');
const playlistsPath = await store.get<string>('playlistsPath');
const overwriteMode = await store.get<OverwriteMode>('overwriteMode');
deviceSyncSettings.set({
musicPath: musicPath ?? null,
playlistsPath: playlistsPath ?? null,
overwriteMode: overwriteMode ?? 'different'
});
}
// Save device music path setting
export async function setMusicPath(path: string | null): Promise<void> {
if (path) {
await store.set('musicPath', path);
} else {
await store.delete('musicPath');
}
await store.save();
deviceSyncSettings.update(s => ({
...s,
musicPath: path
}));
}
// Save device playlists path setting
export async function setPlaylistsPath(path: string | null): Promise<void> {
if (path) {
await store.set('playlistsPath', path);
} else {
await store.delete('playlistsPath');
}
await store.save();
deviceSyncSettings.update(s => ({
...s,
playlistsPath: path
}));
}
// Save overwrite mode setting
export async function setOverwriteMode(mode: OverwriteMode): Promise<void> {
await store.set('overwriteMode', mode);
await store.save();
deviceSyncSettings.update(s => ({
...s,
overwriteMode: mode
}));
}
// Initialize settings on import
loadDeviceSyncSettings();

View File

@@ -0,0 +1,608 @@
<script lang="ts">
import { onMount } from 'svelte';
import { open } from '@tauri-apps/plugin-dialog';
import { exists } from '@tauri-apps/plugin-fs';
import { settings } from '$lib/stores/settings';
import {
deviceSyncSettings,
loadDeviceSyncSettings,
setMusicPath,
setPlaylistsPath,
setOverwriteMode,
type OverwriteMode
} from '$lib/stores/deviceSync';
import {
indexAndCompare,
syncToDevice,
formatBytes,
type SyncDiff,
type SyncProgress
} from '$lib/services/deviceSync';
type ViewMode = 'sync' | 'preferences';
let viewMode = $state<ViewMode>('sync');
let configured = $state(false);
let deviceConnected = $state(false);
let checkingConnection = $state(false);
// Path inputs for initial setup
let musicPathInput = $state('');
let playlistsPathInput = $state('');
// Sync state
let indexing = $state(false);
let syncing = $state(false);
let syncDiff = $state<SyncDiff | null>(null);
let syncProgress = $state<SyncProgress | null>(null);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let selectedFileIndex = $state<number | null>(null);
onMount(async () => {
await loadDeviceSyncSettings();
configured = !!$deviceSyncSettings.musicPath;
if (configured) {
await checkDeviceConnection();
}
});
async function checkDeviceConnection() {
if (!$deviceSyncSettings.musicPath) {
deviceConnected = false;
return;
}
checkingConnection = true;
try {
deviceConnected = await exists($deviceSyncSettings.musicPath);
} catch (e) {
deviceConnected = false;
} finally {
checkingConnection = false;
}
}
async function handleBrowseMusicPath() {
const selected = await open({
directory: true,
multiple: false,
title: 'Select Device Music Folder'
});
if (selected && typeof selected === 'string') {
musicPathInput = selected;
}
}
async function handleBrowsePlaylistsPath() {
const selected = await open({
directory: true,
multiple: false,
title: 'Select Device Playlists Folder'
});
if (selected && typeof selected === 'string') {
playlistsPathInput = selected;
}
}
async function handleSaveConfiguration() {
if (!musicPathInput) {
error = 'Please select a music folder path';
return;
}
error = null;
await setMusicPath(musicPathInput);
await setPlaylistsPath(playlistsPathInput || null);
configured = true;
await checkDeviceConnection();
}
async function handleIndexAndCompare() {
if (!$settings.musicFolder) {
error = 'No library music folder configured. Please set one in Settings.';
return;
}
if (!$deviceSyncSettings.musicPath) {
error = 'No device music path configured.';
return;
}
indexing = true;
error = null;
successMessage = null;
syncDiff = null;
try {
await checkDeviceConnection();
if (!deviceConnected) {
throw new Error('Device is not connected or path does not exist');
}
const result = await indexAndCompare(
$settings.musicFolder,
$deviceSyncSettings.musicPath,
$deviceSyncSettings.overwriteMode
);
syncDiff = result;
successMessage = `Found ${result.filesToCopy.length} files to sync`;
} catch (e) {
error = 'Error indexing device: ' + (e instanceof Error ? e.message : String(e));
syncDiff = null;
} finally {
indexing = false;
}
}
async function handleSync() {
if (!syncDiff || syncDiff.filesToCopy.length === 0) {
error = 'No files to sync. Run Index & Compare first.';
return;
}
if (!$settings.musicFolder || !$deviceSyncSettings.musicPath) {
error = 'Configuration error';
return;
}
syncing = true;
error = null;
successMessage = null;
syncProgress = null;
try {
const result = await syncToDevice(
$settings.musicFolder,
$deviceSyncSettings.musicPath,
syncDiff.filesToCopy,
(progress) => {
syncProgress = progress;
}
);
successMessage = result;
syncDiff = null;
} catch (e) {
error = 'Error syncing to device: ' + (e instanceof Error ? e.message : String(e));
} finally {
syncing = false;
syncProgress = null;
}
}
async function handleUpdateMusicPath() {
const selected = await open({
directory: true,
multiple: false,
title: 'Select Device Music Folder'
});
if (selected && typeof selected === 'string') {
await setMusicPath(selected);
await checkDeviceConnection();
}
}
async function handleUpdatePlaylistsPath() {
const selected = await open({
directory: true,
multiple: false,
title: 'Select Device Playlists Folder'
});
if (selected && typeof selected === 'string') {
await setPlaylistsPath(selected);
}
}
function handleFileClick(index: number) {
selectedFileIndex = index;
}
</script>
<div class="sync-wrapper">
<h2 style="padding: 8px">Device Sync</h2>
{#if !configured}
<!-- Initial Configuration -->
<section class="window config-section" style="max-width: 600px; margin: 8px;">
<div class="title-bar">
<div class="title-bar-text">Configure Device Paths</div>
</div>
<div class="window-body" style="padding: 12px;">
<p>Select the folders on your portable device to sync music and playlists:</p>
<div class="field-row-stacked">
<label for="music-path-input">Device Music Folder</label>
<div class="input-with-button">
<input
id="music-path-input"
type="text"
bind:value={musicPathInput}
placeholder="/Volumes/iPod/Music"
readonly
/>
<button onclick={handleBrowseMusicPath}>Browse...</button>
</div>
</div>
<div class="field-row-stacked">
<label for="playlists-path-input">Device Playlists Folder</label>
<div class="input-with-button">
<input
id="playlists-path-input"
type="text"
bind:value={playlistsPathInput}
placeholder="/Volumes/iPod/Playlists"
readonly
/>
<button onclick={handleBrowsePlaylistsPath}>Browse...</button>
</div>
</div>
{#if error}
<p class="error">{error}</p>
{/if}
<div class="button-row">
<button onclick={handleSaveConfiguration}>Save Configuration</button>
</div>
</div>
</section>
{:else if syncing}
<div class="sync-status">
{#if syncProgress && syncProgress.total > 0}
<p class="progress-text">{syncProgress.current} / {syncProgress.total} files</p>
<div class="progress-bar">
<div
class="progress-fill"
style="width: {(syncProgress.current / syncProgress.total) * 100}%"
></div>
</div>
{#if syncProgress.currentFile}
<p class="current-file">{syncProgress.currentFile}</p>
{/if}
{:else}
<p>Syncing...</p>
{/if}
</div>
{:else if indexing}
<p style="padding: 8px;">Indexing device...</p>
{:else if error}
<p class="error" style="padding: 8px;">{error}</p>
{:else}
<section class="sync-content">
<!-- Tabs -->
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
-->
<menu role="tablist">
<li role="tab" aria-selected={viewMode === 'sync'}>
<button onclick={() => viewMode = 'sync'}>Sync</button>
</li>
<li role="tab" aria-selected={viewMode === 'preferences'}>
<button onclick={() => viewMode = 'preferences'}>Preferences</button>
</li>
</menu>
<!-- Tab Content -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if viewMode === 'sync'}
<!-- Sync Tab -->
<div class="sync-info">
<p>
<strong>Device:</strong>
{#if checkingConnection}
Checking...
{:else if deviceConnected}
Connected - {$deviceSyncSettings.musicPath}
{:else}
Not Connected - {$deviceSyncSettings.musicPath}
{/if}
</p>
<div class="button-group">
<button
onclick={handleIndexAndCompare}
disabled={indexing || syncing || !deviceConnected}
>
{indexing ? 'Indexing...' : 'Index & Compare'}
</button>
<button
onclick={handleSync}
disabled={!syncDiff || syncDiff.filesToCopy.length === 0 || syncing || !deviceConnected}
>
{syncing ? 'Syncing...' : 'Sync to Device'}
</button>
</div>
{#if successMessage}
<p class="success">{successMessage}</p>
{/if}
{#if syncDiff}
<p>
New: {syncDiff.stats.newFiles} | Updated: {syncDiff.stats.updatedFiles} |
Unchanged: {syncDiff.stats.unchangedFiles} |
Total: {formatBytes(syncDiff.stats.totalSize)}
</p>
{/if}
</div>
{#if syncDiff && syncDiff.filesToCopy.length > 0}
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>File</th>
<th>Status</th>
<th>Size</th>
</tr>
</thead>
<tbody>
{#each syncDiff.filesToCopy as file, i}
<tr
class:highlighted={selectedFileIndex === i}
onclick={() => handleFileClick(i)}
>
<td>{file.relativePath}</td>
<td>{file.status}</td>
<td class="size-cell">{formatBytes(file.size)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{:else if viewMode === 'preferences'}
<!-- Preferences Tab -->
<div class="prefs-container">
<fieldset>
<legend>Device Paths</legend>
<div class="field-row-stacked">
<label>Device Music Path</label>
<div class="path-display">
<code>{$deviceSyncSettings.musicPath || 'Not set'}</code>
<button onclick={handleUpdateMusicPath}>Change...</button>
</div>
</div>
<div class="field-row-stacked">
<label>Device Playlists Path</label>
<div class="path-display">
<code>{$deviceSyncSettings.playlistsPath || 'Not set'}</code>
<button onclick={handleUpdatePlaylistsPath}>Change...</button>
</div>
</div>
</fieldset>
<fieldset style="margin-top: 16px;">
<legend>When file exists on device</legend>
<div class="field-row">
<input
type="radio"
id="mode-skip"
name="overwrite-mode"
value="skip"
bind:group={$deviceSyncSettings.overwriteMode}
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
/>
<label for="mode-skip">Skip (don't overwrite)</label>
</div>
<div class="field-row">
<input
type="radio"
id="mode-different"
name="overwrite-mode"
value="different"
bind:group={$deviceSyncSettings.overwriteMode}
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
/>
<label for="mode-different">Overwrite if different size</label>
</div>
<div class="field-row">
<input
type="radio"
id="mode-always"
name="overwrite-mode"
value="always"
bind:group={$deviceSyncSettings.overwriteMode}
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
/>
<label for="mode-always">Always overwrite</label>
</div>
<p class="help-text">
System files and _temp folders are always skipped.
</p>
</fieldset>
</div>
{/if}
</div>
</div>
</section>
{/if}
</div>
<style>
.sync-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
h2 {
margin: 0;
flex-shrink: 0;
}
.sync-status {
padding: 16px 8px;
}
.sync-status p {
margin: 0 0 8px 0;
}
.progress-bar {
width: 100%;
height: 20px;
background: #c0c0c0;
border: 2px inset #808080;
margin-bottom: 4px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #000080, #0000ff);
transition: width 0.2s ease;
}
.progress-text {
font-size: 12px;
color: #808080;
}
.current-file {
font-size: 11px;
font-family: monospace;
color: #808080;
margin: 4px 0 0 0;
word-break: break-all;
}
.config-section {
margin-bottom: 16px;
}
.field-row-stacked {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.input-with-button {
display: flex;
gap: 8px;
}
.input-with-button input {
flex: 1;
padding: 4px;
}
.field-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
.button-row {
margin-top: 12px;
display: flex;
gap: 8px;
}
.button-group {
display: flex;
gap: 8px;
margin: 8px 0;
}
.error {
color: #ff6b6b;
}
.success {
color: #808080;
}
.sync-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content {
margin-top: -2px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.sync-info {
padding: 16px 8px;
flex-shrink: 0;
}
.sync-info p {
margin: 0 0 8px 0;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
table {
width: 100%;
}
th {
text-align: left;
}
.size-cell {
text-align: right;
font-family: monospace;
font-size: 0.9em;
width: 100px;
}
.prefs-container {
padding: 16px;
overflow-y: auto;
}
.path-display {
display: flex;
gap: 8px;
align-items: center;
}
.path-display code {
flex: 1;
padding: 4px 8px;
background: var(--button-shadow, #2a2a2a);
border: 1px solid var(--button-highlight, #606060);
font-family: monospace;
font-size: 0.9em;
}
.help-text {
margin: 8px 0 0 0;
font-size: 11px;
color: #808080;
}
</style>