fix: route CapturesTab exports through the platform layer

The wav/txt/md export handlers called plugin-dialog and plugin-fs
directly, which breaks in the web build. saveFile now returns the
saved path (null on cancel) so callers can toast accurately, and the
cross-window capture listeners only register under Tauri.
This commit is contained in:
Jamie Pine
2026-08-08 15:15:14 -07:00
parent 901ffcc93b
commit 46cf5803a3
4 changed files with 115 additions and 96 deletions
+110 -94
View File
@@ -1,8 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { save } from '@tauri-apps/plugin-dialog';
import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs';
import { import {
Captions, Captions,
Check, Check,
@@ -27,6 +25,14 @@ import { AudioBars } from '@/components/AudioBars';
import { CapturePill } from '@/components/CapturePill/CapturePill'; import { CapturePill } from '@/components/CapturePill/CapturePill';
import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer'; import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist'; import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
import {
ListPane,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -48,14 +54,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import {
ListPane,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import type { import type {
@@ -72,6 +70,7 @@ import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { formatAbsoluteDate, formatDate } from '@/lib/utils/format'; import { formatAbsoluteDate, formatDate } from '@/lib/utils/format';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes'; import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import { usePlatform } from '@/platform/PlatformContext';
import { useGenerationStore } from '@/stores/generationStore'; import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
@@ -135,6 +134,7 @@ export function CapturesTab() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
const platform = usePlatform();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null); const uploadInputRef = useRef<HTMLInputElement>(null);
@@ -202,6 +202,7 @@ export function CapturesTab() {
// the race window between ``setSelectedId(new)`` and the refetched list // the race window between ``setSelectedId(new)`` and the refetched list
// actually containing the new row. // actually containing the new row.
useEffect(() => { useEffect(() => {
if (!platform.metadata.isTauri) return;
const unlistens: Promise<UnlistenFn>[] = []; const unlistens: Promise<UnlistenFn>[] = [];
unlistens.push( unlistens.push(
listen<{ capture: CaptureResponse }>('capture:created', (event) => { listen<{ capture: CaptureResponse }>('capture:created', (event) => {
@@ -225,7 +226,7 @@ export function CapturesTab() {
return () => { return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {}); for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
}; };
}, [queryClient]); }, [queryClient, platform.metadata.isTauri]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
@@ -243,9 +244,7 @@ export function CapturesTab() {
// referenced profile was deleted) fall through to the first profile. // referenced profile was deleted) fall through to the first profile.
const storedVoiceId = captureSettings?.default_playback_voice_id ?? null; const storedVoiceId = captureSettings?.default_playback_voice_id ?? null;
const playAsVoice = const playAsVoice =
(storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) || (storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) || profiles?.[0] || null;
profiles?.[0] ||
null;
const playAsVoiceId = playAsVoice?.id ?? null; const playAsVoiceId = playAsVoice?.id ?? null;
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -255,12 +254,22 @@ export function CapturesTab() {
queryClient.invalidateQueries({ queryKey: ['captures'] }); queryClient.invalidateQueries({ queryKey: ['captures'] });
}, },
onError: (err: Error) => { onError: (err: Error) => {
toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' }); toast({
title: t('captures.toast.deleteFailed'),
description: err.message,
variant: 'destructive',
});
}, },
}); });
const playAsMutation = useMutation({ const playAsMutation = useMutation({
mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => { mutationFn: async ({
capture,
voice,
}: {
capture: CaptureResponse;
voice: VoiceProfileResponse;
}) => {
const text = capture.transcript_refined || capture.transcript_raw; const text = capture.transcript_refined || capture.transcript_raw;
if (!text.trim()) throw new Error(t('captures.noTranscriptError')); if (!text.trim()) throw new Error(t('captures.noTranscriptError'));
const language = (capture.language || voice.language) as LanguageCode; const language = (capture.language || voice.language) as LanguageCode;
@@ -268,8 +277,13 @@ export function CapturesTab() {
// profile's stored engine preference. Cloned profiles without an // profile's stored engine preference. Cloned profiles without an
// override fall through to whatever the backend picks. // override fall through to whatever the backend picks.
const engine = voice.default_engine as const engine = voice.default_engine as
| 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox' | 'qwen'
| 'chatterbox_turbo' | 'tada' | 'kokoro' | 'qwen_custom_voice'
| 'luxtts'
| 'chatterbox'
| 'chatterbox_turbo'
| 'tada'
| 'kokoro'
| undefined; | undefined;
return apiClient.generateSpeech({ return apiClient.generateSpeech({
profile_id: voice.id, profile_id: voice.id,
@@ -286,7 +300,11 @@ export function CapturesTab() {
addPendingGeneration(result.id); addPendingGeneration(result.id);
}, },
onError: (err: Error) => { onError: (err: Error) => {
toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' }); toast({
title: t('captures.toast.playAsFailed'),
description: err.message,
variant: 'destructive',
});
}, },
}); });
@@ -336,16 +354,15 @@ export function CapturesTab() {
const handleExportAudio = async () => { const handleExportAudio = async () => {
if (!selected) return; if (!selected) return;
try { try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.wav`,
filters: [{ name: 'Audio', extensions: ['wav'] }],
});
if (!dest) return;
const res = await fetch(apiClient.getCaptureAudioUrl(selected.id)); const res = await fetch(apiClient.getCaptureAudioUrl(selected.id));
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = new Uint8Array(await res.arrayBuffer()); const blob = new Blob([await res.arrayBuffer()], { type: 'audio/wav' });
await writeFile(dest, buf); const dest = await platform.filesystem.saveFile(
exportToastSuccess(dest); `capture_${selected.id.slice(0, 8)}.wav`,
blob,
[{ name: 'Audio', extensions: ['wav'] }],
);
if (dest) exportToastSuccess(dest);
} catch (err) { } catch (err) {
exportToastError(err); exportToastError(err);
} }
@@ -359,13 +376,12 @@ export function CapturesTab() {
return; return;
} }
try { try {
const dest = await save({ const dest = await platform.filesystem.saveFile(
defaultPath: `capture_${selected.id.slice(0, 8)}.txt`, `capture_${selected.id.slice(0, 8)}.txt`,
filters: [{ name: 'Text', extensions: ['txt'] }], new Blob([text], { type: 'text/plain' }),
}); [{ name: 'Text', extensions: ['txt'] }],
if (!dest) return; );
await writeTextFile(dest, text); if (dest) exportToastSuccess(dest);
exportToastSuccess(dest);
} catch (err) { } catch (err) {
exportToastError(err); exportToastError(err);
} }
@@ -376,7 +392,8 @@ export function CapturesTab() {
lines.push(`# Capture ${capture.id}`, ''); lines.push(`# Capture ${capture.id}`, '');
lines.push(`- **Source:** ${capture.source}`); lines.push(`- **Source:** ${capture.source}`);
lines.push(`- **Created:** ${capture.created_at}`); lines.push(`- **Created:** ${capture.created_at}`);
if (capture.duration_ms != null) lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`); if (capture.duration_ms != null)
lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`);
if (capture.language) lines.push(`- **Language:** ${capture.language}`); if (capture.language) lines.push(`- **Language:** ${capture.language}`);
if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`); if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`);
if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`); if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`);
@@ -398,13 +415,12 @@ export function CapturesTab() {
return; return;
} }
try { try {
const dest = await save({ const dest = await platform.filesystem.saveFile(
defaultPath: `capture_${selected.id.slice(0, 8)}.md`, `capture_${selected.id.slice(0, 8)}.md`,
filters: [{ name: 'Markdown', extensions: ['md'] }], new Blob([buildCaptureMarkdown(selected)], { type: 'text/markdown' }),
}); [{ name: 'Markdown', extensions: ['md'] }],
if (!dest) return; );
await writeTextFile(dest, buildCaptureMarkdown(selected)); if (dest) exportToastSuccess(dest);
exportToastSuccess(dest);
} catch (err) { } catch (err) {
exportToastError(err); exportToastError(err);
} }
@@ -486,48 +502,48 @@ export function CapturesTab() {
</div> </div>
) : ( ) : (
filtered.map((capture) => { filtered.map((capture) => {
const isActive = selectedId === capture.id; const isActive = selectedId === capture.id;
const refined = !!capture.transcript_refined; const refined = !!capture.transcript_refined;
return ( return (
<button <button
type="button" type="button"
key={capture.id} key={capture.id}
onClick={() => setSelectedId(capture.id)} onClick={() => setSelectedId(capture.id)}
className={cn( className={cn(
'w-full text-left p-3 rounded-lg transition-colors block', 'w-full text-left p-3 rounded-lg transition-colors block',
isActive isActive
? 'bg-muted/70 border border-border' ? 'bg-muted/70 border border-border'
: 'border border-transparent hover:bg-muted/30', : 'border border-transparent hover:bg-muted/30',
)}
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium">
{formatDate(capture.created_at)}
</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
{formatDuration(capture.duration_ms)}
</span>
</div>
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
{snippetOf(capture)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<SourceBadge source={capture.source} />
{refined && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
>
<Sparkles className="h-2.5 w-2.5" />
{t('captures.transcript.refined')}
</Badge>
)} )}
</div> >
</button> <div className="flex items-center gap-2 mb-1.5">
); <span className="text-[11px] text-muted-foreground font-medium">
}) {formatDate(capture.created_at)}
)} </span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
{formatDuration(capture.duration_ms)}
</span>
</div>
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
{snippetOf(capture)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<SourceBadge source={capture.source} />
{refined && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
>
<Sparkles className="h-2.5 w-2.5" />
{t('captures.transcript.refined')}
</Badge>
)}
</div>
</button>
);
})
)}
</div> </div>
</ListPaneScroll> </ListPaneScroll>
</ListPane> </ListPane>
@@ -578,7 +594,9 @@ export function CapturesTab() {
) : ( ) : (
<Upload className="h-4 w-4 mr-2" /> <Upload className="h-4 w-4 mr-2" />
)} )}
{session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')} {session.isUploading
? t('captures.actions.importing')
: t('captures.actions.import')}
</Button> </Button>
)} )}
</> </>
@@ -748,11 +766,7 @@ export function CapturesTab() {
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{profiles?.map((v) => ( {profiles?.map((v) => (
<DropdownMenuItem <DropdownMenuItem key={v.id} onClick={() => handlePlayAs(v)} className="py-2">
key={v.id}
onClick={() => handlePlayAs(v)}
className="py-2"
>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{v.name}</div> <div className="text-sm font-medium truncate">{v.name}</div>
<div className="text-[11px] text-muted-foreground truncate"> <div className="text-[11px] text-muted-foreground truncate">
@@ -864,9 +878,7 @@ export function CapturesTab() {
</div> </div>
) : null} ) : null}
</div> </div>
<p className="text-sm"> <p className="text-sm">{t('captures.empty.pressShortcut')}</p>
{t('captures.empty.pressShortcut')}
</p>
</div> </div>
) : ( ) : (
<div className="max-w-sm mx-auto text-center space-y-3"> <div className="max-w-sm mx-auto text-center space-y-3">
@@ -888,7 +900,9 @@ export function CapturesTab() {
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t('captures.deleteDialog.title')}</AlertDialogTitle> <AlertDialogTitle>{t('captures.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('captures.deleteDialog.description')}</AlertDialogDescription> <AlertDialogDescription>
{t('captures.deleteDialog.description')}
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel> <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
@@ -898,7 +912,9 @@ export function CapturesTab() {
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{deleteMutation.isPending ? t('captures.deleteDialog.deleting') : t('common.delete')} {deleteMutation.isPending
? t('captures.deleteDialog.deleting')
: t('common.delete')}
</Button> </Button>
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
+2 -1
View File
@@ -9,7 +9,8 @@ export interface FileFilter {
} }
export interface PlatformFilesystem { export interface PlatformFilesystem {
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>; /** Returns the saved path (or filename on web), or null if the user cancelled. */
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<string | null>;
openPath(path: string): Promise<void>; openPath(path: string): Promise<void>;
pickDirectory(title: string): Promise<string | null>; pickDirectory(title: string): Promise<string | null>;
} }
+2 -1
View File
@@ -10,7 +10,7 @@ export const tauriFilesystem: PlatformFilesystem = {
filters: filters || [], filters: filters || [],
}); });
if (!filePath) return; // User cancelled the dialog if (!filePath) return null; // User cancelled the dialog
const resolvedPath = const resolvedPath =
typeof filePath === 'string' ? filePath : (filePath as { path: string }).path; typeof filePath === 'string' ? filePath : (filePath as { path: string }).path;
@@ -21,6 +21,7 @@ export const tauriFilesystem: PlatformFilesystem = {
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
await writeFile(resolvedPath, new Uint8Array(arrayBuffer)); await writeFile(resolvedPath, new Uint8Array(arrayBuffer));
return resolvedPath;
}, },
async openPath(path: string) { async openPath(path: string) {
+1
View File
@@ -11,6 +11,7 @@ export const webFilesystem: PlatformFilesystem = {
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(a);
return filename;
}, },
async openPath(_path: string) { async openPath(_path: string) {