refactor: use Immer for immutable state updates (#527)
* refactor: use Immer for immutable state updates Replace Set/Map with Record types and use Immer's produce() for immutable state updates. This fixes mutation bugs where .add()/.set() were mutating state before copying (e.g., `new Set(prev.add(id))`). Changes: - Add immer dependency - Convert Set<string> to Record<string, true> (sparse hash set pattern) - Convert Map<K,V> to Record<K,V> - Use produce() for all state mutations - Update prop types in child components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * move deps back to where they started --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
parent
c84e863423
commit
f9d2aa68c3
5 changed files with 97 additions and 88 deletions
3
bun.lock
3
bun.lock
|
|
@ -18,6 +18,7 @@
|
|||
"@tauri-apps/plugin-store": "~2.4.1",
|
||||
"@tauri-apps/plugin-updater": "~2.9.0",
|
||||
"i18next": "^25.7.2",
|
||||
"immer": "^11.1.3",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
|
@ -493,6 +494,8 @@
|
|||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
"react-select": "^5.8.0",
|
||||
"tauri-plugin-macos-permissions-api": "2.3.0",
|
||||
"i18next": "^25.7.2",
|
||||
"immer": "^11.1.3",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ interface DownloadStats {
|
|||
}
|
||||
|
||||
interface DownloadProgressDisplayProps {
|
||||
downloadProgress: Map<string, DownloadProgress>;
|
||||
downloadStats: Map<string, DownloadStats>;
|
||||
downloadProgress: Record<string, DownloadProgress>;
|
||||
downloadStats: Record<string, DownloadStats>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -26,14 +26,13 @@ const DownloadProgressDisplay: React.FC<DownloadProgressDisplayProps> = ({
|
|||
downloadStats,
|
||||
className = "",
|
||||
}) => {
|
||||
if (downloadProgress.size === 0) {
|
||||
const progressValues = Object.values(downloadProgress);
|
||||
if (progressValues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const progressData: ProgressData[] = Array.from(
|
||||
downloadProgress.values(),
|
||||
).map((progress) => {
|
||||
const stats = downloadStats.get(progress.model_id);
|
||||
const progressData: ProgressData[] = progressValues.map((progress) => {
|
||||
const stats = downloadStats[progress.model_id];
|
||||
return {
|
||||
id: progress.model_id,
|
||||
percentage: progress.percentage,
|
||||
|
|
@ -45,7 +44,7 @@ const DownloadProgressDisplay: React.FC<DownloadProgressDisplayProps> = ({
|
|||
<ProgressBar
|
||||
progress={progressData}
|
||||
className={className}
|
||||
showSpeed={downloadProgress.size === 1}
|
||||
showSpeed={progressValues.length === 1}
|
||||
size="medium"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ interface DownloadProgress {
|
|||
interface ModelDropdownProps {
|
||||
models: ModelInfo[];
|
||||
currentModelId: string;
|
||||
downloadProgress: Map<string, DownloadProgress>;
|
||||
downloadProgress: Record<string, DownloadProgress>;
|
||||
onModelSelect: (modelId: string) => void;
|
||||
onModelDownload: (modelId: string) => void;
|
||||
onModelDelete: (modelId: string) => Promise<void>;
|
||||
|
|
@ -52,14 +52,14 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
|||
};
|
||||
|
||||
const handleModelClick = (modelId: string) => {
|
||||
if (downloadProgress.has(modelId)) {
|
||||
if (modelId in downloadProgress) {
|
||||
return; // Don't allow interaction while downloading
|
||||
}
|
||||
onModelSelect(modelId);
|
||||
};
|
||||
|
||||
const handleDownloadClick = (modelId: string) => {
|
||||
if (downloadProgress.has(modelId)) {
|
||||
if (modelId in downloadProgress) {
|
||||
return; // Don't allow interaction while downloading
|
||||
}
|
||||
onModelDownload(modelId);
|
||||
|
|
@ -158,8 +158,8 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
|||
: t("modelSelector.downloadModels")}
|
||||
</div>
|
||||
{downloadableModels.map((model) => {
|
||||
const isDownloading = downloadProgress.has(model.id);
|
||||
const progress = downloadProgress.get(model.id);
|
||||
const isDownloading = model.id in downloadProgress;
|
||||
const progress = downloadProgress[model.id];
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { produce } from "immer";
|
||||
import { commands, type ModelInfo } from "@/bindings";
|
||||
import { getTranslatedModelName } from "../../lib/utils/modelTranslation";
|
||||
import ModelStatusButton from "./ModelStatusButton";
|
||||
|
|
@ -48,15 +49,15 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
|||
const [modelStatus, setModelStatus] = useState<ModelStatus>("unloaded");
|
||||
const [modelError, setModelError] = useState<string | null>(null);
|
||||
const [modelDownloadProgress, setModelDownloadProgress] = useState<
|
||||
Map<string, DownloadProgress>
|
||||
>(new Map());
|
||||
Record<string, DownloadProgress>
|
||||
>({});
|
||||
const [showModelDropdown, setShowModelDropdown] = useState(false);
|
||||
const [downloadStats, setDownloadStats] = useState<
|
||||
Map<string, DownloadStats>
|
||||
>(new Map());
|
||||
const [extractingModels, setExtractingModels] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
Record<string, DownloadStats>
|
||||
>({});
|
||||
const [extractingModels, setExtractingModels] = useState<
|
||||
Record<string, true>
|
||||
>({});
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -97,53 +98,52 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
|||
"model-download-progress",
|
||||
(event) => {
|
||||
const progress = event.payload;
|
||||
setModelDownloadProgress((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(progress.model_id, progress);
|
||||
return newMap;
|
||||
});
|
||||
setModelDownloadProgress(
|
||||
produce((downloadProgress) => {
|
||||
downloadProgress[progress.model_id] = progress;
|
||||
}),
|
||||
);
|
||||
setModelStatus("downloading");
|
||||
|
||||
// Update download stats for speed calculation
|
||||
const now = Date.now();
|
||||
setDownloadStats((prev) => {
|
||||
const current = prev.get(progress.model_id);
|
||||
const newStats = new Map(prev);
|
||||
setDownloadStats(
|
||||
produce((stats) => {
|
||||
const current = stats[progress.model_id];
|
||||
|
||||
if (!current) {
|
||||
// First progress update - initialize
|
||||
newStats.set(progress.model_id, {
|
||||
startTime: now,
|
||||
lastUpdate: now,
|
||||
totalDownloaded: progress.downloaded,
|
||||
speed: 0,
|
||||
});
|
||||
} else {
|
||||
// Calculate speed over last few seconds
|
||||
const timeDiff = (now - current.lastUpdate) / 1000; // seconds
|
||||
const bytesDiff = progress.downloaded - current.totalDownloaded;
|
||||
|
||||
if (timeDiff > 0.5) {
|
||||
// Update speed every 500ms
|
||||
const currentSpeed = bytesDiff / (1024 * 1024) / timeDiff; // MB/s
|
||||
// Smooth the speed with exponential moving average, but ensure positive values
|
||||
const validCurrentSpeed = Math.max(0, currentSpeed);
|
||||
const smoothedSpeed =
|
||||
current.speed > 0
|
||||
? current.speed * 0.8 + validCurrentSpeed * 0.2
|
||||
: validCurrentSpeed;
|
||||
|
||||
newStats.set(progress.model_id, {
|
||||
startTime: current.startTime,
|
||||
if (!current) {
|
||||
// First progress update - initialize
|
||||
stats[progress.model_id] = {
|
||||
startTime: now,
|
||||
lastUpdate: now,
|
||||
totalDownloaded: progress.downloaded,
|
||||
speed: Math.max(0, smoothedSpeed),
|
||||
});
|
||||
}
|
||||
}
|
||||
speed: 0,
|
||||
};
|
||||
} else {
|
||||
// Calculate speed over last few seconds
|
||||
const timeDiff = (now - current.lastUpdate) / 1000; // seconds
|
||||
const bytesDiff = progress.downloaded - current.totalDownloaded;
|
||||
|
||||
return newStats;
|
||||
});
|
||||
if (timeDiff > 0.5) {
|
||||
// Update speed every 500ms
|
||||
const currentSpeed = bytesDiff / (1024 * 1024) / timeDiff; // MB/s
|
||||
// Smooth the speed with exponential moving average, but ensure positive values
|
||||
const validCurrentSpeed = Math.max(0, currentSpeed);
|
||||
const smoothedSpeed =
|
||||
current.speed > 0
|
||||
? current.speed * 0.8 + validCurrentSpeed * 0.2
|
||||
: validCurrentSpeed;
|
||||
|
||||
stats[progress.model_id] = {
|
||||
startTime: current.startTime,
|
||||
lastUpdate: now,
|
||||
totalDownloaded: progress.downloaded,
|
||||
speed: Math.max(0, smoothedSpeed),
|
||||
};
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -152,16 +152,16 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
|||
"model-download-complete",
|
||||
(event) => {
|
||||
const modelId = event.payload;
|
||||
setModelDownloadProgress((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(modelId);
|
||||
return newMap;
|
||||
});
|
||||
setDownloadStats((prev) => {
|
||||
const newStats = new Map(prev);
|
||||
newStats.delete(modelId);
|
||||
return newStats;
|
||||
});
|
||||
setModelDownloadProgress(
|
||||
produce((progress) => {
|
||||
delete progress[modelId];
|
||||
}),
|
||||
);
|
||||
setDownloadStats(
|
||||
produce((stats) => {
|
||||
delete stats[modelId];
|
||||
}),
|
||||
);
|
||||
loadModels(); // Refresh models list
|
||||
|
||||
// Auto-select the newly downloaded model (skip if recording in progress)
|
||||
|
|
@ -181,7 +181,11 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
|||
"model-extraction-started",
|
||||
(event) => {
|
||||
const modelId = event.payload;
|
||||
setExtractingModels((prev) => new Set(prev.add(modelId)));
|
||||
setExtractingModels(
|
||||
produce((extracting) => {
|
||||
extracting[modelId] = true;
|
||||
}),
|
||||
);
|
||||
setModelStatus("extracting");
|
||||
},
|
||||
);
|
||||
|
|
@ -190,11 +194,11 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
|||
"model-extraction-completed",
|
||||
(event) => {
|
||||
const modelId = event.payload;
|
||||
setExtractingModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(modelId);
|
||||
return next;
|
||||
});
|
||||
setExtractingModels(
|
||||
produce((extracting) => {
|
||||
delete extracting[modelId];
|
||||
}),
|
||||
);
|
||||
loadModels(); // Refresh models list
|
||||
|
||||
// Auto-select the newly extracted model (skip if recording in progress)
|
||||
|
|
@ -214,11 +218,11 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
|||
error: string;
|
||||
}>("model-extraction-failed", (event) => {
|
||||
const modelId = event.payload.model_id;
|
||||
setExtractingModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(modelId);
|
||||
return next;
|
||||
});
|
||||
setExtractingModels(
|
||||
produce((extracting) => {
|
||||
delete extracting[modelId];
|
||||
}),
|
||||
);
|
||||
setModelError(`Failed to extract model: ${event.payload.error}`);
|
||||
setModelStatus("error");
|
||||
});
|
||||
|
|
@ -329,9 +333,10 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
|||
};
|
||||
|
||||
const getModelDisplayText = (): string => {
|
||||
if (extractingModels.size > 0) {
|
||||
if (extractingModels.size === 1) {
|
||||
const [modelId] = Array.from(extractingModels);
|
||||
const extractingKeys = Object.keys(extractingModels);
|
||||
if (extractingKeys.length > 0) {
|
||||
if (extractingKeys.length === 1) {
|
||||
const modelId = extractingKeys[0];
|
||||
const model = models.find((m) => m.id === modelId);
|
||||
const modelName = model
|
||||
? getTranslatedModelName(model, t)
|
||||
|
|
@ -339,14 +344,15 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
|||
return t("modelSelector.extracting", { modelName });
|
||||
} else {
|
||||
return t("modelSelector.extractingMultiple", {
|
||||
count: extractingModels.size,
|
||||
count: extractingKeys.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (modelDownloadProgress.size > 0) {
|
||||
if (modelDownloadProgress.size === 1) {
|
||||
const [progress] = Array.from(modelDownloadProgress.values());
|
||||
const progressValues = Object.values(modelDownloadProgress);
|
||||
if (progressValues.length > 0) {
|
||||
if (progressValues.length === 1) {
|
||||
const progress = progressValues[0];
|
||||
const percentage = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(progress.percentage)),
|
||||
|
|
@ -354,7 +360,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
|||
return t("modelSelector.downloading", { percentage });
|
||||
} else {
|
||||
return t("modelSelector.downloadingMultiple", {
|
||||
count: modelDownloadProgress.size,
|
||||
count: progressValues.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue