From fe649eba9f473ab887de3e73a98340ed4e98ae2e Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Wed, 17 Dec 2025 20:54:03 -0300 Subject: [PATCH 01/22] fix: update CI badge in README to reflect current branch --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2ea9ba2..8066839 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ๐ŸŽ™๏ธ WhisperDesk -[![CI](https://github.com/PVAS-Development/whisperdesk/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/PVAS-Development/whisperdesk/actions/workflows/ci.yml) +[![Downloads](https://img.shields.io/github/downloads/PVAS-Development/whisperdesk/total.svg)](https://github.com/PVAS-Development/whisperdesk/releases) [![Release Version](https://img.shields.io/github/v/release/PVAS-Development/whisperdesk?label=release&logo=github)](https://github.com/PVAS-Development/whisperdesk/releases) [![Lint](https://img.shields.io/badge/lint-passing-brightgreen.svg?logo=eslint&logoColor=white)](https://github.com/PVAS-Development/whisperdesk/actions/workflows/ci.yml) [![Stars](https://img.shields.io/github/stars/PVAS-Development/whisperdesk?style=social)](https://github.com/PVAS-Development/whisperdesk/stargazers) From 883b21463ecfe6bd7c13ef4e0e2204dd141de50c Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Wed, 17 Dec 2025 21:02:24 -0300 Subject: [PATCH 02/22] feat: add Privacy Policy & Terms page and update README for privacy details --- README.md | 11 ++- docs/index.html | 1 + docs/privacy.html | 184 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 docs/privacy.html diff --git a/README.md b/README.md index 8066839..a52cdfd 100644 --- a/README.md +++ b/README.md @@ -348,11 +348,14 @@ Contributions are welcome! Please see our [Contributing Guide](CONTRIBUTING.md) - Code style and commit conventions - Submitting pull requests -## ๐Ÿ”’ Security +## ๐Ÿ”’ Privacy & Security -- All audio/video processing happens **locally** on your device -- No data is sent to external servers -- The app is **code-signed and notarized** by Apple +- **Local Processing**: All audio/video processing happens **locally** on your device. Your files never leave your computer. +- **No Cloud Uploads**: We do not upload your media files or transcriptions to any server. +- **Anonymous Analytics**: We collect minimal, anonymous usage data (e.g., app launches, feature usage) to improve the app. No personal data or file content is collected. +- **Code Signing**: The app is **code-signed and notarized** by Apple for your safety. + +For more details, please read our [Privacy Policy](https://whisperdesk.org/privacy.html). ## ๐Ÿ“„ License diff --git a/docs/index.html b/docs/index.html index 8bfe172..8c0ce71 100644 --- a/docs/index.html +++ b/docs/index.html @@ -339,6 +339,7 @@

Open Source & Community Drive rel="noopener noreferrer" class="footer-link">License Contributing + Privacy & Terms GitHub diff --git a/docs/privacy.html b/docs/privacy.html new file mode 100644 index 0000000..dc2df45 --- /dev/null +++ b/docs/privacy.html @@ -0,0 +1,184 @@ + + + + + + + Privacy Policy & Terms - WhisperDesk + + + + + + + + + + +
+ + + Back to Home + + +

Privacy Policy & Terms

+

Last updated: December 17, 2025

+ +
+

Privacy Policy

+

At WhisperDesk, we believe your data belongs to you. Our application is designed with privacy as its core principle.

+ +

1. Local Processing

+

All audio and video transcription is performed locally on your device.

+

WhisperDesk uses the whisper.cpp engine to process files directly on your Mac. Your audio files, video files, and transcriptions are never uploaded to any cloud server or third-party service for processing. They never leave your computer.

+ +

2. Data Collection

+

We collect anonymous usage data to help us improve the application, fix bugs, and understand which features are most valuable to our users. This data is collected via Aptabase, a privacy-first analytics provider that is fully GDPR, CCPA, and PECR compliant.

+ +

3. What We Collect

+
    +
  • App Lifecycle: When the app is opened or closed.
  • +
  • Feature Usage: Anonymous statistics such as "transcription started", "model downloaded", or "export saved".
  • +
  • System Information: Basic details like your OS version (e.g., macOS 14.0) and App version (e.g., v1.6.2).
  • +
  • Error Logs: If the app crashes or encounters an error, we receive a log to help us fix it. These logs are sanitized to remove any file paths, file names, or personal information before they are sent.
  • +
+ +

4. What We Do NOT Collect

+
    +
  • No File Content: We never access or transmit the audio/video content you transcribe.
  • +
  • No Transcriptions: We never access or transmit the text generated by the transcription.
  • +
  • No File Names: We do not track the names of the files you open.
  • +
  • No Personal Information: We do not collect your name, email, IP address, or any other personally identifiable information (PII).
  • +
+
+ +
+

Terms of Service

+ +

1. License

+

WhisperDesk is open-source software licensed under the MIT License. You are free to use, modify, and distribute the software in accordance with the license terms.

+ +

2. Disclaimer

+

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ +

3. User Responsibility

+

You are solely responsible for the content you transcribe using WhisperDesk. Please ensure you have the necessary rights and permissions to process any audio or video files you use with the application.

+
+
+ + + + + + From 093d79f9806c595d2e8b20b49eb244bceb4f028f Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Wed, 17 Dec 2025 21:06:16 -0300 Subject: [PATCH 03/22] feat: add support section with donation links to README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index a52cdfd..536494c 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,15 @@ Contributions are welcome! Please see our [Contributing Guide](CONTRIBUTING.md) For more details, please read our [Privacy Policy](https://whisperdesk.org/privacy.html). +## โ˜• Support the Project + +WhisperDesk is free and open-source software. If you find it useful, please consider supporting its development: + +- [**Donate via PayPal**](https://www.paypal.com/donate/?hosted_button_id=HTJXGMEGMWWD6) +- [**Buy me a coffee**](https://www.buymeacoffee.com/pedrovsiqueira) + +Your support helps cover the costs of Apple Developer Program fees and keeps the project alive! + ## ๐Ÿ“„ License MIT License - see [LICENSE](LICENSE) for details. From 058b76c9cf154096bb4fac3d62ac24612ab17042 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Wed, 17 Dec 2025 22:44:50 -0300 Subject: [PATCH 04/22] refactor: change default exports to named exports for consistency --- src/renderer/App.tsx | 2 +- src/renderer/components/layout/AppHeader/AppHeader.tsx | 2 +- src/renderer/components/layout/AppHeader/index.ts | 2 +- src/renderer/components/layout/LeftPanel/LeftPanel.tsx | 2 +- src/renderer/components/layout/LeftPanel/index.ts | 2 +- src/renderer/components/layout/RightPanel/RightPanel.tsx | 2 +- src/renderer/components/layout/RightPanel/index.ts | 2 +- src/renderer/components/ui/ProgressBar/ProgressBar.tsx | 4 ++-- src/renderer/components/ui/ProgressBar/index.ts | 3 ++- src/renderer/components/ui/SystemWarning/SystemWarning.tsx | 2 +- .../ui/SystemWarning/__tests__/SystemWarning.test.tsx | 2 +- src/renderer/components/ui/SystemWarning/index.ts | 3 ++- .../components/TranscriptionHistory/TranscriptionHistory.tsx | 4 ++-- .../features/history/components/TranscriptionHistory/index.ts | 3 ++- src/renderer/features/history/components/index.ts | 1 + .../settings/components/SettingsPanel/SettingsPanel.tsx | 4 ++-- .../components/SettingsPanel/__tests__/SettingsPanel.test.tsx | 2 +- .../features/settings/components/SettingsPanel/index.ts | 3 ++- src/renderer/features/settings/components/index.ts | 1 + .../transcription/components/FileDropZone/FileDropZone.tsx | 2 +- .../features/transcription/components/FileDropZone/index.ts | 2 +- .../transcription/components/OutputDisplay/OutputDisplay.tsx | 2 +- .../features/transcription/components/OutputDisplay/index.ts | 2 +- src/renderer/main.tsx | 2 +- 24 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3a768b0..525606d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -21,4 +21,4 @@ function App(): React.JSX.Element { ); } -export default App; +export { App }; diff --git a/src/renderer/components/layout/AppHeader/AppHeader.tsx b/src/renderer/components/layout/AppHeader/AppHeader.tsx index 241fdbb..8645338 100644 --- a/src/renderer/components/layout/AppHeader/AppHeader.tsx +++ b/src/renderer/components/layout/AppHeader/AppHeader.tsx @@ -44,4 +44,4 @@ function AppHeader(): React.JSX.Element { ); } -export default AppHeader; +export { AppHeader }; diff --git a/src/renderer/components/layout/AppHeader/index.ts b/src/renderer/components/layout/AppHeader/index.ts index 6b822c4..ffd39ab 100644 --- a/src/renderer/components/layout/AppHeader/index.ts +++ b/src/renderer/components/layout/AppHeader/index.ts @@ -1 +1 @@ -export { default as AppHeader } from './AppHeader'; +export { AppHeader } from './AppHeader'; diff --git a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx index 8e79218..c9b89e3 100644 --- a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx +++ b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx @@ -72,4 +72,4 @@ function LeftPanel(): React.JSX.Element { ); } -export default LeftPanel; +export { LeftPanel }; diff --git a/src/renderer/components/layout/LeftPanel/index.ts b/src/renderer/components/layout/LeftPanel/index.ts index 9a0b431..4939aae 100644 --- a/src/renderer/components/layout/LeftPanel/index.ts +++ b/src/renderer/components/layout/LeftPanel/index.ts @@ -1,4 +1,4 @@ -export { default as LeftPanel } from './LeftPanel'; +export { LeftPanel } from './LeftPanel'; export { TranscriptionActions } from './TranscriptionActions'; export { TranscriptionProgress } from './TranscriptionProgress'; export { ErrorMessage } from './ErrorMessage'; diff --git a/src/renderer/components/layout/RightPanel/RightPanel.tsx b/src/renderer/components/layout/RightPanel/RightPanel.tsx index 0824b60..2de6872 100644 --- a/src/renderer/components/layout/RightPanel/RightPanel.tsx +++ b/src/renderer/components/layout/RightPanel/RightPanel.tsx @@ -40,4 +40,4 @@ function RightPanel(): React.JSX.Element { ); } -export default RightPanel; +export { RightPanel }; diff --git a/src/renderer/components/layout/RightPanel/index.ts b/src/renderer/components/layout/RightPanel/index.ts index 91c7766..7e0c5a0 100644 --- a/src/renderer/components/layout/RightPanel/index.ts +++ b/src/renderer/components/layout/RightPanel/index.ts @@ -1 +1 @@ -export { default as RightPanel } from './RightPanel'; +export { RightPanel } from './RightPanel'; diff --git a/src/renderer/components/ui/ProgressBar/ProgressBar.tsx b/src/renderer/components/ui/ProgressBar/ProgressBar.tsx index cb48135..85d3984 100644 --- a/src/renderer/components/ui/ProgressBar/ProgressBar.tsx +++ b/src/renderer/components/ui/ProgressBar/ProgressBar.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { formatTime } from '../../../utils'; import './ProgressBar.css'; -interface ProgressBarProps { +export interface ProgressBarProps { percent: number; status: string; startTime: number | null; @@ -59,4 +59,4 @@ function ProgressBar({ ); } -export default ProgressBar; +export { ProgressBar }; diff --git a/src/renderer/components/ui/ProgressBar/index.ts b/src/renderer/components/ui/ProgressBar/index.ts index e072ff5..3b5109c 100644 --- a/src/renderer/components/ui/ProgressBar/index.ts +++ b/src/renderer/components/ui/ProgressBar/index.ts @@ -1 +1,2 @@ -export { default as ProgressBar } from './ProgressBar'; +export { ProgressBar } from './ProgressBar'; +export type { ProgressBarProps } from './ProgressBar'; diff --git a/src/renderer/components/ui/SystemWarning/SystemWarning.tsx b/src/renderer/components/ui/SystemWarning/SystemWarning.tsx index 2bc6627..efad6f8 100644 --- a/src/renderer/components/ui/SystemWarning/SystemWarning.tsx +++ b/src/renderer/components/ui/SystemWarning/SystemWarning.tsx @@ -150,4 +150,4 @@ function SystemWarning({ onRefresh }: SystemWarningProps): React.JSX.Element { ); } -export default SystemWarning; +export { SystemWarning }; diff --git a/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx b/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx index 1f5fc60..111d95b 100644 --- a/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx +++ b/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import SystemWarning from '../SystemWarning'; +import { SystemWarning } from '../SystemWarning'; import { overrideElectronAPI } from '../../../../test/utils'; const mockCopyToClipboard = vi.fn(); diff --git a/src/renderer/components/ui/SystemWarning/index.ts b/src/renderer/components/ui/SystemWarning/index.ts index df65498..698c7f7 100644 --- a/src/renderer/components/ui/SystemWarning/index.ts +++ b/src/renderer/components/ui/SystemWarning/index.ts @@ -1 +1,2 @@ -export { default as SystemWarning } from './SystemWarning'; +export { SystemWarning } from './SystemWarning'; +export type { SystemWarningProps } from './SystemWarning'; diff --git a/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx b/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx index 28ca2d0..b82ab2a 100644 --- a/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx +++ b/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx @@ -6,7 +6,7 @@ import './TranscriptionHistory.css'; import type { HistoryItem } from '../../../../types'; -interface TranscriptionHistoryProps { +export interface TranscriptionHistoryProps { history: HistoryItem[]; onClear: () => void; onClose: () => void; @@ -110,4 +110,4 @@ function TranscriptionHistory({ ); } -export default TranscriptionHistory; +export { TranscriptionHistory }; diff --git a/src/renderer/features/history/components/TranscriptionHistory/index.ts b/src/renderer/features/history/components/TranscriptionHistory/index.ts index db631d8..1c29297 100644 --- a/src/renderer/features/history/components/TranscriptionHistory/index.ts +++ b/src/renderer/features/history/components/TranscriptionHistory/index.ts @@ -1 +1,2 @@ -export { default as TranscriptionHistory } from './TranscriptionHistory'; +export { TranscriptionHistory } from './TranscriptionHistory'; +export type { TranscriptionHistoryProps } from './TranscriptionHistory'; diff --git a/src/renderer/features/history/components/index.ts b/src/renderer/features/history/components/index.ts index 4755226..1c29297 100644 --- a/src/renderer/features/history/components/index.ts +++ b/src/renderer/features/history/components/index.ts @@ -1 +1,2 @@ export { TranscriptionHistory } from './TranscriptionHistory'; +export type { TranscriptionHistoryProps } from './TranscriptionHistory'; diff --git a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx index 199105e..d244336 100644 --- a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx +++ b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx @@ -13,7 +13,7 @@ import type { import { LANGUAGES, QUALITY_STARS } from '../../../../config'; import { DEFAULT_MODELS } from '../../services/modelService'; -interface SettingsPanelProps { +export interface SettingsPanelProps { settings: TranscriptionSettings; onChange: (settings: TranscriptionSettings) => void; disabled: boolean; @@ -265,4 +265,4 @@ function SettingsPanel({ ); } -export default SettingsPanel; +export { SettingsPanel }; diff --git a/src/renderer/features/settings/components/SettingsPanel/__tests__/SettingsPanel.test.tsx b/src/renderer/features/settings/components/SettingsPanel/__tests__/SettingsPanel.test.tsx index 5c3a0bd..0137a6c 100644 --- a/src/renderer/features/settings/components/SettingsPanel/__tests__/SettingsPanel.test.tsx +++ b/src/renderer/features/settings/components/SettingsPanel/__tests__/SettingsPanel.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import SettingsPanel from '../SettingsPanel'; +import { SettingsPanel } from '../SettingsPanel'; import type { TranscriptionSettings } from '../../../../../types'; import { overrideElectronAPI } from '../../../../../test/utils'; import { MOCK_SETTINGS, createMockModels, MOCK_GPU_INFO } from '../../../../../test/fixtures'; diff --git a/src/renderer/features/settings/components/SettingsPanel/index.ts b/src/renderer/features/settings/components/SettingsPanel/index.ts index 975f4d8..ccbadc7 100644 --- a/src/renderer/features/settings/components/SettingsPanel/index.ts +++ b/src/renderer/features/settings/components/SettingsPanel/index.ts @@ -1 +1,2 @@ -export { default as SettingsPanel } from './SettingsPanel'; +export { SettingsPanel } from './SettingsPanel'; +export type { SettingsPanelProps } from './SettingsPanel'; diff --git a/src/renderer/features/settings/components/index.ts b/src/renderer/features/settings/components/index.ts index a71f257..ccbadc7 100644 --- a/src/renderer/features/settings/components/index.ts +++ b/src/renderer/features/settings/components/index.ts @@ -1 +1,2 @@ export { SettingsPanel } from './SettingsPanel'; +export type { SettingsPanelProps } from './SettingsPanel'; diff --git a/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx b/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx index df3470c..5113200 100644 --- a/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx +++ b/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx @@ -109,4 +109,4 @@ function FileDropZone({ ); } -export default FileDropZone; +export { FileDropZone }; diff --git a/src/renderer/features/transcription/components/FileDropZone/index.ts b/src/renderer/features/transcription/components/FileDropZone/index.ts index 52a69c7..4d955c3 100644 --- a/src/renderer/features/transcription/components/FileDropZone/index.ts +++ b/src/renderer/features/transcription/components/FileDropZone/index.ts @@ -1,2 +1,2 @@ -export { default as FileDropZone } from './FileDropZone'; +export { FileDropZone } from './FileDropZone'; export type { FileDropZoneProps } from './FileDropZone'; diff --git a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx index 3b68166..825e1ee 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx +++ b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx @@ -302,4 +302,4 @@ function OutputDisplay({ ); } -export default OutputDisplay; +export { OutputDisplay }; diff --git a/src/renderer/features/transcription/components/OutputDisplay/index.ts b/src/renderer/features/transcription/components/OutputDisplay/index.ts index 2b9f4db..307c215 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/index.ts +++ b/src/renderer/features/transcription/components/OutputDisplay/index.ts @@ -1,2 +1,2 @@ -export { default as OutputDisplay } from './OutputDisplay'; +export { OutputDisplay } from './OutputDisplay'; export type { OutputDisplayProps } from './OutputDisplay'; diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 6efc256..7a58780 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import App from './App'; +import { App } from './App'; import './index.css'; const rootElement = document.getElementById('root'); From 4e20511c95466bff066ac2f31508565aff235842 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Wed, 17 Dec 2025 22:50:25 -0300 Subject: [PATCH 05/22] feat: add ErrorMessage, TranscriptionActions, and TranscriptionProgress components --- .../layout/LeftPanel/{ => ErrorMessage}/ErrorMessage.tsx | 2 +- .../components/layout/LeftPanel/ErrorMessage/index.ts | 1 + .../{ => TranscriptionActions}/TranscriptionActions.tsx | 2 +- .../components/layout/LeftPanel/TranscriptionActions/index.ts | 1 + .../{ => TranscriptionProgress}/TranscriptionProgress.tsx | 4 ++-- .../layout/LeftPanel/TranscriptionProgress/index.ts | 1 + 6 files changed, 7 insertions(+), 4 deletions(-) rename src/renderer/components/layout/LeftPanel/{ => ErrorMessage}/ErrorMessage.tsx (86%) create mode 100644 src/renderer/components/layout/LeftPanel/ErrorMessage/index.ts rename src/renderer/components/layout/LeftPanel/{ => TranscriptionActions}/TranscriptionActions.tsx (94%) create mode 100644 src/renderer/components/layout/LeftPanel/TranscriptionActions/index.ts rename src/renderer/components/layout/LeftPanel/{ => TranscriptionProgress}/TranscriptionProgress.tsx (80%) create mode 100644 src/renderer/components/layout/LeftPanel/TranscriptionProgress/index.ts diff --git a/src/renderer/components/layout/LeftPanel/ErrorMessage.tsx b/src/renderer/components/layout/LeftPanel/ErrorMessage/ErrorMessage.tsx similarity index 86% rename from src/renderer/components/layout/LeftPanel/ErrorMessage.tsx rename to src/renderer/components/layout/LeftPanel/ErrorMessage/ErrorMessage.tsx index 154b177..18bc3f7 100644 --- a/src/renderer/components/layout/LeftPanel/ErrorMessage.tsx +++ b/src/renderer/components/layout/LeftPanel/ErrorMessage/ErrorMessage.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { AlertTriangle } from 'lucide-react'; -import { useAppTranscription } from '../../../contexts'; +import { useAppTranscription } from '../../../../contexts'; export function ErrorMessage(): React.JSX.Element | null { const { error } = useAppTranscription(); diff --git a/src/renderer/components/layout/LeftPanel/ErrorMessage/index.ts b/src/renderer/components/layout/LeftPanel/ErrorMessage/index.ts new file mode 100644 index 0000000..1f26cdd --- /dev/null +++ b/src/renderer/components/layout/LeftPanel/ErrorMessage/index.ts @@ -0,0 +1 @@ +export { ErrorMessage } from './ErrorMessage'; diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionActions.tsx b/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx similarity index 94% rename from src/renderer/components/layout/LeftPanel/TranscriptionActions.tsx rename to src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx index 313f49c..5d4dd78 100644 --- a/src/renderer/components/layout/LeftPanel/TranscriptionActions.tsx +++ b/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Zap } from 'lucide-react'; -import { useAppTranscription } from '../../../contexts'; +import { useAppTranscription } from '../../../../contexts'; export function TranscriptionActions(): React.JSX.Element { const { selectedFile, isTranscribing, modelDownloaded, handleTranscribe, handleCancel } = diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionActions/index.ts b/src/renderer/components/layout/LeftPanel/TranscriptionActions/index.ts new file mode 100644 index 0000000..051556a --- /dev/null +++ b/src/renderer/components/layout/LeftPanel/TranscriptionActions/index.ts @@ -0,0 +1 @@ +export { TranscriptionActions } from './TranscriptionActions'; diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionProgress.tsx b/src/renderer/components/layout/LeftPanel/TranscriptionProgress/TranscriptionProgress.tsx similarity index 80% rename from src/renderer/components/layout/LeftPanel/TranscriptionProgress.tsx rename to src/renderer/components/layout/LeftPanel/TranscriptionProgress/TranscriptionProgress.tsx index 9d978ba..2cd8d89 100644 --- a/src/renderer/components/layout/LeftPanel/TranscriptionProgress.tsx +++ b/src/renderer/components/layout/LeftPanel/TranscriptionProgress/TranscriptionProgress.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { ProgressBar } from '../../ui'; -import { useAppTranscription } from '../../../contexts'; +import { ProgressBar } from '../../../ui'; +import { useAppTranscription } from '../../../../contexts'; export function TranscriptionProgress(): React.JSX.Element | null { const { isTranscribing, progress, transcriptionStartTime } = useAppTranscription(); diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionProgress/index.ts b/src/renderer/components/layout/LeftPanel/TranscriptionProgress/index.ts new file mode 100644 index 0000000..a1936ee --- /dev/null +++ b/src/renderer/components/layout/LeftPanel/TranscriptionProgress/index.ts @@ -0,0 +1 @@ +export { TranscriptionProgress } from './TranscriptionProgress'; From 39764d3505bc56f51793b6ea6a2419bb55ae11d3 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Wed, 17 Dec 2025 22:55:25 -0300 Subject: [PATCH 06/22] test: move test files --- .../__tests__/TranscriptionHistory.test.tsx | 0 .../settings/__tests__/SettingsPanel.test.tsx | 353 ------------------ .../__tests__/FileDropZone.test.tsx | 0 .../__tests__/OutputDisplay.test.tsx | 0 4 files changed, 353 deletions(-) rename src/renderer/features/history/{ => components/TranscriptionHistory}/__tests__/TranscriptionHistory.test.tsx (100%) delete mode 100644 src/renderer/features/settings/__tests__/SettingsPanel.test.tsx rename src/renderer/features/transcription/{ => components/FileDropZone}/__tests__/FileDropZone.test.tsx (100%) rename src/renderer/features/transcription/{ => components/OutputDisplay}/__tests__/OutputDisplay.test.tsx (100%) diff --git a/src/renderer/features/history/__tests__/TranscriptionHistory.test.tsx b/src/renderer/features/history/components/TranscriptionHistory/__tests__/TranscriptionHistory.test.tsx similarity index 100% rename from src/renderer/features/history/__tests__/TranscriptionHistory.test.tsx rename to src/renderer/features/history/components/TranscriptionHistory/__tests__/TranscriptionHistory.test.tsx diff --git a/src/renderer/features/settings/__tests__/SettingsPanel.test.tsx b/src/renderer/features/settings/__tests__/SettingsPanel.test.tsx deleted file mode 100644 index e419c57..0000000 --- a/src/renderer/features/settings/__tests__/SettingsPanel.test.tsx +++ /dev/null @@ -1,353 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; -import { SettingsPanel } from '@/features/settings'; -import { overrideElectronAPI } from '@/test/utils'; -import { MOCK_SETTINGS, createMockModels } from '@/test/fixtures'; - -describe('SettingsPanel', () => { - const mockSettings = { ...MOCK_SETTINGS }; - const mockModels = createMockModels(3, [false, true, false]); - - beforeEach(() => { - vi.clearAllMocks(); - overrideElectronAPI({ - listModels: vi.fn().mockResolvedValue({ models: mockModels }), - getGpuStatus: vi.fn().mockResolvedValue({ - available: true, - name: 'NVIDIA RTX 3080', - memory: '10 GB', - }), - onModelDownloadProgress: vi.fn().mockReturnValue(() => {}), - }); - }); - - it('should render settings panel with title', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByText(/Settings/i)).toBeInTheDocument(); - }); - }); - - it('should load and display models on mount', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(window.electronAPI?.listModels).toHaveBeenCalled(); - }); - }); - - it('should display model selection dropdown', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Select Whisper model/i)).toBeInTheDocument(); - }); - }); - - it('should display language selection dropdown', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - const langSelect = screen.getByLabelText(/Select transcription language/i); - expect(langSelect).toBeInTheDocument(); - }); - }); - - it('should call onChange when model selection changes', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Select Whisper model/i)).toBeInTheDocument(); - }); - - const modelSelect = screen.getByLabelText(/Select Whisper model/i) as HTMLSelectElement; - fireEvent.change(modelSelect, { target: { value: 'small' } }); - - await waitFor(() => { - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ model: 'small' })); - }); - }); - - it('should call onChange when language selection changes', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - const languageSelect = screen.getByLabelText(/Select transcription language/i); - fireEvent.change(languageSelect, { target: { value: 'en' } }); - }); - - await waitFor(() => { - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ language: 'en' })); - }); - }); - - it('should disable controls when disabled prop is true', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - const modelSelect = screen.getByLabelText(/Select Whisper model/i); - expect(modelSelect).toBeDisabled(); - }); - }); - - it('should call onModelStatusChange when model download status changes', async () => { - const onChange = vi.fn(); - const onModelStatusChange = vi.fn(); - - render( - - ); - - await waitFor(() => { - expect(window.electronAPI?.listModels).toHaveBeenCalled(); - }); - }); - - it('should display GPU status when available', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByText(/GPU|RTX 3080/i)).toBeInTheDocument(); - }); - }); - - it('should handle loadModelInfo error gracefully', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - overrideElectronAPI({ - listModels: vi.fn().mockRejectedValue(new Error('Network error')), - getGpuStatus: vi.fn().mockRejectedValue(new Error('GPU error')), - onModelDownloadProgress: vi.fn().mockReturnValue(() => {}), - }); - - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(consoleSpy).toHaveBeenCalled(); - }); - - consoleSpy.mockRestore(); - }); - - it('should handle model download error', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const downloadedModels = createMockModels(3, [false, false, false]); - overrideElectronAPI({ - listModels: vi.fn().mockResolvedValue({ models: downloadedModels }), - getGpuStatus: vi.fn().mockResolvedValue({ - available: true, - name: 'GPU', - memory: '8 GB', - }), - onModelDownloadProgress: vi.fn().mockReturnValue(() => {}), - downloadModel: vi.fn().mockRejectedValue(new Error('Download failed')), - }); - - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Download/i)).toBeInTheDocument(); - }); - - const downloadButton = screen.getByLabelText(/Download/i); - fireEvent.click(downloadButton); - - await waitFor(() => { - expect(consoleSpy).toHaveBeenCalled(); - }); - - consoleSpy.mockRestore(); - }); - - it('should persist model selection to localStorage', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Select Whisper model/i)).toBeInTheDocument(); - }); - - const modelSelect = screen.getByLabelText(/Select Whisper model/i) as HTMLSelectElement; - fireEvent.change(modelSelect, { target: { value: 'small' } }); - - expect(localStorage.getItem('whisperdesk_lastModel')).toBe('small'); - }); - - it('should show download progress during model download', async () => { - let progressCallback: ((data: { status: string; percent: number }) => void) | undefined; - const downloadedModels = createMockModels(3, [false, false, false]); - - overrideElectronAPI({ - listModels: vi.fn().mockResolvedValue({ models: downloadedModels }), - getGpuStatus: vi.fn().mockResolvedValue({ - available: true, - name: 'GPU', - memory: '8 GB', - }), - onModelDownloadProgress: vi.fn((callback) => { - progressCallback = callback; - return () => {}; - }), - downloadModel: vi.fn().mockImplementation( - () => - new Promise((resolve) => { - setTimeout(resolve, 1000); - }) - ), - }); - - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Download/i)).toBeInTheDocument(); - }); - - const downloadButton = screen.getByLabelText(/Download/i); - fireEvent.click(downloadButton); - - await waitFor(() => { - expect(screen.getByText(/Downloading/i)).toBeInTheDocument(); - }); - - const cb = progressCallback; - if (cb) { - act(() => { - cb({ status: 'progress', percent: 50 }); - }); - } - - await waitFor(() => { - expect(screen.getByText(/50%/i)).toBeInTheDocument(); - }); - }); - - it('should handle delete model confirmation cancel', async () => { - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); - const downloadedModels = createMockModels(3, [true, true, true]); - - overrideElectronAPI({ - listModels: vi.fn().mockResolvedValue({ models: downloadedModels }), - getGpuStatus: vi.fn().mockResolvedValue({ - available: true, - name: 'GPU', - memory: '8 GB', - }), - onModelDownloadProgress: vi.fn().mockReturnValue(() => {}), - deleteModel: vi.fn(), - }); - - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Delete/i)).toBeInTheDocument(); - }); - - const deleteButton = screen.getByLabelText(/Delete/i); - fireEvent.click(deleteButton); - - expect(window.electronAPI?.deleteModel).not.toHaveBeenCalled(); - confirmSpy.mockRestore(); - }); - - it('should handle delete model failure', async () => { - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); - const downloadedModels = createMockModels(3, [true, true, true]); - - overrideElectronAPI({ - listModels: vi.fn().mockResolvedValue({ models: downloadedModels }), - getGpuStatus: vi.fn().mockResolvedValue({ - available: true, - name: 'GPU', - memory: '8 GB', - }), - onModelDownloadProgress: vi.fn().mockReturnValue(() => {}), - deleteModel: vi.fn().mockResolvedValue({ success: false, error: 'Permission denied' }), - }); - - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Delete/i)).toBeInTheDocument(); - }); - - const deleteButton = screen.getByLabelText(/Delete/i); - fireEvent.click(deleteButton); - - await waitFor(() => { - expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('Permission denied')); - }); - - confirmSpy.mockRestore(); - alertSpy.mockRestore(); - }); - - it('should handle delete model exception', async () => { - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const downloadedModels = createMockModels(3, [true, true, true]); - - overrideElectronAPI({ - listModels: vi.fn().mockResolvedValue({ models: downloadedModels }), - getGpuStatus: vi.fn().mockResolvedValue({ - available: true, - name: 'GPU', - memory: '8 GB', - }), - onModelDownloadProgress: vi.fn().mockReturnValue(() => {}), - deleteModel: vi.fn().mockRejectedValue(new Error('Network error')), - }); - - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Delete/i)).toBeInTheDocument(); - }); - - const deleteButton = screen.getByLabelText(/Delete/i); - fireEvent.click(deleteButton); - - await waitFor(() => { - expect(consoleSpy).toHaveBeenCalled(); - }); - - confirmSpy.mockRestore(); - alertSpy.mockRestore(); - consoleSpy.mockRestore(); - }); -}); diff --git a/src/renderer/features/transcription/__tests__/FileDropZone.test.tsx b/src/renderer/features/transcription/components/FileDropZone/__tests__/FileDropZone.test.tsx similarity index 100% rename from src/renderer/features/transcription/__tests__/FileDropZone.test.tsx rename to src/renderer/features/transcription/components/FileDropZone/__tests__/FileDropZone.test.tsx diff --git a/src/renderer/features/transcription/__tests__/OutputDisplay.test.tsx b/src/renderer/features/transcription/components/OutputDisplay/__tests__/OutputDisplay.test.tsx similarity index 100% rename from src/renderer/features/transcription/__tests__/OutputDisplay.test.tsx rename to src/renderer/features/transcription/components/OutputDisplay/__tests__/OutputDisplay.test.tsx From f12d01a69363a2cfd09473b7abf9b0e1e846ad6b Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Wed, 17 Dec 2025 23:12:49 -0300 Subject: [PATCH 07/22] feat: add .nvmrc file and update package.json with node engine requirements --- .nvmrc | 1 + README.md | 2 +- package-lock.json | 251 +++++++++++++++++----------------------------- package.json | 5 +- 4 files changed, 96 insertions(+), 163 deletions(-) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index 536494c..c7ae1f6 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ Models are downloaded automatically on first use and cached in: ### Prerequisites -- Node.js 18+ +- Node.js 22.12+ (use `nvm use` to auto-switch via `.nvmrc`) - CMake (for building whisper.cpp) - FFmpeg diff --git a/package-lock.json b/package-lock.json index b57da62..6998e79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "husky": "^9.1.7", - "jsdom": "^27.2.0", + "jsdom": "^25.0.1", "lint-staged": "^16.2.7", "prettier": "^3.7.3", "semantic-release": "^24.2.9", @@ -58,13 +58,6 @@ "wait-on": "^9.0.3" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.24", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.24.tgz", - "integrity": "sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==", - "dev": true, - "license": "MIT" - }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -82,59 +75,25 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz", - "integrity": "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.2" + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.5", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.5.tgz", - "integrity": "sha512-Eks6dY8zau4m4wNRQjRVaKQRTalNcPcBvU1ZQ35w5kKRk1gUeNCkVLsRiATurjASTp3TKM4H10wsI50nx3NZdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.2" - } - }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "MIT" + "license": "ISC" }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -988,26 +947,6 @@ "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.20.tgz", - "integrity": "sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", @@ -5617,16 +5556,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -6776,20 +6705,6 @@ "utrie": "^1.0.2" } }, - "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -6798,20 +6713,26 @@ "license": "MIT" }, "node_modules/cssstyle": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", - "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", - "css-tree": "^3.1.0" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">=20" + "node": ">=18" } }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -6833,17 +6754,17 @@ } }, "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/debug": { @@ -9853,38 +9774,39 @@ } }, "node_modules/jsdom": { - "version": "27.2.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", - "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.23", - "@asamuzakjp/dom-selector": "^6.7.4", - "cssstyle": "^5.3.3", - "data-urls": "^6.0.0", - "decimal.js": "^10.6.0", + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", + "https-proxy-agent": "^7.0.5", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", + "tough-cookie": "^5.0.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", + "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=18" }, "peerDependencies": { - "canvas": "^3.0.0" + "canvas": "^2.11.2" }, "peerDependenciesMeta": { "canvas": { @@ -9893,9 +9815,9 @@ } }, "node_modules/jsdom/node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { @@ -11007,13 +10929,6 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/meow": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", @@ -14245,6 +14160,13 @@ "inBundle": true, "license": "ISC" }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -15549,6 +15471,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -17158,22 +17087,22 @@ } }, "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.19" + "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, "license": "MIT" }, @@ -17221,29 +17150,29 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "tldts": "^7.0.5" + "tldts": "^6.1.32" }, "engines": { "node": ">=16" } }, "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/traverse": { @@ -18858,13 +18787,13 @@ "license": "Apache-2.0" }, "node_modules/webidl-conversions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", - "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=20" + "node": ">=12" } }, "node_modules/whatwg-encoding": { @@ -18891,17 +18820,17 @@ } }, "node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/which": { diff --git a/package.json b/package.json index 6d1bf97..1dd07ae 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "url": "https://github.com/PVAS-Development/whisperdesk/issues" }, "homepage": "https://github.com/PVAS-Development/whisperdesk#readme", + "engines": { + "node": ">=22.12.0" + }, "main": "dist-electron/main.cjs", "author": { "name": "Pedro Siqueira", @@ -97,7 +100,7 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "husky": "^9.1.7", - "jsdom": "^27.2.0", + "jsdom": "^25.0.1", "lint-staged": "^16.2.7", "prettier": "^3.7.3", "semantic-release": "^24.2.9", From fd703693b8baa3ccc6985a1fbb4e161bcbf1f1e7 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Wed, 17 Dec 2025 23:13:06 -0300 Subject: [PATCH 08/22] test: refactor SystemWarning tests to use act for event handling and timer advances --- .../__tests__/SystemWarning.test.tsx | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx b/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx index 111d95b..73d0083 100644 --- a/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx +++ b/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { SystemWarning } from '../SystemWarning'; import { overrideElectronAPI } from '../../../../test/utils'; @@ -65,10 +65,17 @@ describe('SystemWarning', () => { render(); const refreshButton = screen.getByRole('button', { name: /I have installed FFmpeg/i }); - fireEvent.click(refreshButton); + + await act(async () => { + fireEvent.click(refreshButton); + }); expect(mockOnRefresh).toHaveBeenCalled(); expect(screen.getByText(/Verifying Installation.../i)).toBeInTheDocument(); + + await waitFor(() => { + expect(refreshButton).not.toBeDisabled(); + }); }); it('copies command to clipboard', async () => { @@ -160,7 +167,10 @@ describe('SystemWarning', () => { render(); const refreshButton = screen.getByRole('button', { name: /I have installed FFmpeg/i }); - fireEvent.click(refreshButton); + + await act(async () => { + fireEvent.click(refreshButton); + }); await waitFor(() => { expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -224,11 +234,16 @@ describe('SystemWarning', () => { render(); const refreshButton = screen.getByRole('button', { name: /I have installed FFmpeg/i }); - fireEvent.click(refreshButton); + + await act(async () => { + fireEvent.click(refreshButton); + }); expect(mockOnRefresh).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1000); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); expect(mockOnRefresh).toHaveBeenCalledTimes(2); @@ -236,12 +251,16 @@ describe('SystemWarning', () => { }); it('handles onRefresh error', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); mockOnRefresh.mockRejectedValue(new Error('Refresh failed')); render(); const refreshButton = screen.getByRole('button', { name: /I have installed FFmpeg/i }); - fireEvent.click(refreshButton); + + await act(async () => { + fireEvent.click(refreshButton); + }); await waitFor(() => { expect(mockOnRefresh).toHaveBeenCalled(); @@ -250,5 +269,7 @@ describe('SystemWarning', () => { await waitFor(() => { expect(refreshButton).not.toBeDisabled(); }); + + consoleErrorSpy.mockRestore(); }); }); From 4cc92bb2be9519794a291b98ca6b3adbdd975972 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Wed, 17 Dec 2025 23:39:58 -0300 Subject: [PATCH 09/22] feat: implement ErrorBoundary component with error handling UI and styles --- src/renderer/App.tsx | 23 ++-- .../ui/ErrorBoundary/ErrorBoundary.css | 87 ++++++++++++ .../ui/ErrorBoundary/ErrorBoundary.tsx | 81 ++++++++++++ .../__tests__/ErrorBoundary.test.tsx | 125 ++++++++++++++++++ .../components/ui/ErrorBoundary/index.ts | 2 + src/renderer/components/ui/index.ts | 2 + 6 files changed, 310 insertions(+), 10 deletions(-) create mode 100644 src/renderer/components/ui/ErrorBoundary/ErrorBoundary.css create mode 100644 src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx create mode 100644 src/renderer/components/ui/ErrorBoundary/__tests__/ErrorBoundary.test.tsx create mode 100644 src/renderer/components/ui/ErrorBoundary/index.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 525606d..69a70e6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,23 +1,26 @@ import React from 'react'; import { AppProvider } from './contexts'; import { AppHeader, LeftPanel, RightPanel } from './components'; +import { ErrorBoundary } from './components/ui'; import { UpdateNotification } from './features/auto-update'; import './App.css'; function App(): React.JSX.Element { return ( - -
- + + +
+ -
- - -
+
+ + +
- -
-
+ +
+
+ ); } diff --git a/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.css b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.css new file mode 100644 index 0000000..63ad10f --- /dev/null +++ b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.css @@ -0,0 +1,87 @@ +.error-boundary { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem; + background: var(--bg-primary, #1a1a2e); + color: var(--text-primary, #e0e0e0); +} + +.error-boundary-content { + max-width: 480px; + text-align: center; +} + +.error-boundary-icon { + color: var(--color-warning, #f59e0b); + margin-bottom: 1.5rem; +} + +.error-boundary-title { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 0.75rem; + color: var(--text-primary, #e0e0e0); +} + +.error-boundary-message { + font-size: 0.95rem; + color: var(--text-secondary, #a0a0a0); + margin: 0 0 1.5rem; + line-height: 1.5; +} + +.error-boundary-details { + background: var(--bg-secondary, #252540); + border: 1px solid var(--border-color, #3a3a5c); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; + font-family: monospace; + font-size: 0.85rem; + color: var(--color-error, #ef4444); + text-align: left; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.error-boundary-actions { + display: flex; + gap: 0.75rem; + justify-content: center; + flex-wrap: wrap; +} + +.error-boundary-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + border: none; +} + +.error-boundary-btn-primary { + background: var(--color-primary, #6366f1); + color: white; +} + +.error-boundary-btn-primary:hover { + background: var(--color-primary-hover, #5558e3); +} + +.error-boundary-btn-secondary { + background: var(--bg-secondary, #252540); + color: var(--text-primary, #e0e0e0); + border: 1px solid var(--border-color, #3a3a5c); +} + +.error-boundary-btn-secondary:hover { + background: var(--bg-tertiary, #2d2d4a); +} diff --git a/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 0000000..75238ac --- /dev/null +++ b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,81 @@ +import React, { type ReactNode } from 'react'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; +import './ErrorBoundary.css'; + +export interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + if (process.env.NODE_ENV === 'development') { + console.error('ErrorBoundary caught an error:', error); + console.error('Component stack:', errorInfo.componentStack); + } + } + + handleReload = (): void => { + window.location.reload(); + }; + + handleReset = (): void => { + this.setState({ hasError: false, error: null }); + }; + + override render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+
+
+ ); + } + + return this.props.children; + } +} + +export { ErrorBoundary }; diff --git a/src/renderer/components/ui/ErrorBoundary/__tests__/ErrorBoundary.test.tsx b/src/renderer/components/ui/ErrorBoundary/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 0000000..8817b86 --- /dev/null +++ b/src/renderer/components/ui/ErrorBoundary/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ErrorBoundary } from '../ErrorBoundary'; + +function ThrowingComponent(): never { + throw new Error('Test error message'); +} + +describe('ErrorBoundary', () => { + const originalError = console.error; + beforeEach(() => { + console.error = vi.fn(); + }); + + afterEach(() => { + console.error = originalError; + }); + + it('renders children when there is no error', () => { + render( + +
Child content
+
+ ); + + expect(screen.getByText('Child content')).toBeInTheDocument(); + }); + + it('renders fallback UI when child throws', () => { + render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect( + screen.getByText('An unexpected error occurred. Please try reloading the application.') + ).toBeInTheDocument(); + }); + + it('renders custom fallback when provided', () => { + render( + Custom fallback}> + + + ); + + expect(screen.getByText('Custom fallback')).toBeInTheDocument(); + expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument(); + }); + + it('shows reload and try again buttons', () => { + render( + + + + ); + + expect(screen.getByRole('button', { name: /reload application/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + it('calls window.location.reload when reload button is clicked', () => { + const reloadMock = vi.fn(); + Object.defineProperty(window, 'location', { + value: { reload: reloadMock }, + writable: true, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /reload application/i })); + expect(reloadMock).toHaveBeenCalled(); + }); + + it('resets error state when try again button is clicked', () => { + let shouldThrow = true; + + function ToggleableThrower(): React.JSX.Element { + if (shouldThrow) { + throw new Error('Initial error'); + } + return
Recovered content
; + } + + const { rerender } = render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + + shouldThrow = false; + fireEvent.click(screen.getByRole('button', { name: /try again/i })); + + rerender( + + + + ); + + expect(screen.getByText('Recovered content')).toBeInTheDocument(); + }); + + it('does not show error details in production', () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + render( + + + + ); + + expect(screen.queryByText('Test error message')).not.toBeInTheDocument(); + + process.env.NODE_ENV = originalNodeEnv; + }); +}); diff --git a/src/renderer/components/ui/ErrorBoundary/index.ts b/src/renderer/components/ui/ErrorBoundary/index.ts new file mode 100644 index 0000000..7aff0c7 --- /dev/null +++ b/src/renderer/components/ui/ErrorBoundary/index.ts @@ -0,0 +1,2 @@ +export { ErrorBoundary } from './ErrorBoundary'; +export type { ErrorBoundaryProps } from './ErrorBoundary'; diff --git a/src/renderer/components/ui/index.ts b/src/renderer/components/ui/index.ts index b82acb0..35504fc 100644 --- a/src/renderer/components/ui/index.ts +++ b/src/renderer/components/ui/index.ts @@ -1,2 +1,4 @@ +export { ErrorBoundary } from './ErrorBoundary'; +export type { ErrorBoundaryProps } from './ErrorBoundary'; export { ProgressBar } from './ProgressBar'; export { SystemWarning } from './SystemWarning'; From c7891a527523fd91bd0b152f211676c7f691d15c Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Wed, 17 Dec 2025 23:41:21 -0300 Subject: [PATCH 10/22] refactor: split TranscriptionContext for performance --- src/renderer/contexts/AppContext.tsx | 55 ++++++++++++++++++++++------ src/renderer/contexts/contexts.ts | 12 +++++- src/renderer/contexts/hooks.ts | 32 +++++++++++++++- src/renderer/contexts/types.ts | 7 +++- 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/src/renderer/contexts/AppContext.tsx b/src/renderer/contexts/AppContext.tsx index e3e28e8..3aac94e 100644 --- a/src/renderer/contexts/AppContext.tsx +++ b/src/renderer/contexts/AppContext.tsx @@ -3,8 +3,20 @@ import { useTranscription } from '../features/transcription'; import { useHistory } from '../features/history'; import { useTheme, useCopyToClipboard, useElectronMenu } from '../hooks'; import type { HistoryItem } from '../types'; -import { ThemeContext, HistoryContext, TranscriptionContext } from './contexts'; -import type { ThemeContextValue, HistoryContextValue, TranscriptionContextValue } from './types'; +import { + ThemeContext, + HistoryContext, + TranscriptionContext, + TranscriptionStateContext, + TranscriptionActionsContext, +} from './contexts'; +import type { + ThemeContextValue, + HistoryContextValue, + TranscriptionContextValue, + TranscriptionStateContextValue, + TranscriptionActionsContextValue, +} from './types'; interface AppProviderProps { children: ReactNode; @@ -116,7 +128,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { ] ); - const transcriptionContextValue = useMemo( + const transcriptionStateValue = useMemo( () => ({ selectedFile, settings, @@ -127,14 +139,6 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { error, modelDownloaded, copySuccess, - setSelectedFile, - setSettings, - setModelDownloaded, - handleFileSelect, - handleTranscribe, - handleCancel, - handleSave, - handleCopy: onCopy, }), [ selectedFile, @@ -146,6 +150,21 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { error, modelDownloaded, copySuccess, + ] + ); + + const transcriptionActionsValue = useMemo( + () => ({ + setSelectedFile, + setSettings, + setModelDownloaded, + handleFileSelect, + handleTranscribe, + handleCancel, + handleSave, + handleCopy: onCopy, + }), + [ setSelectedFile, setSettings, setModelDownloaded, @@ -157,11 +176,23 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { ] ); + const transcriptionContextValue = useMemo( + () => ({ + ...transcriptionStateValue, + ...transcriptionActionsValue, + }), + [transcriptionStateValue, transcriptionActionsValue] + ); + return ( - {children} + + + {children} + + diff --git a/src/renderer/contexts/contexts.ts b/src/renderer/contexts/contexts.ts index 24f86b3..e186ff2 100644 --- a/src/renderer/contexts/contexts.ts +++ b/src/renderer/contexts/contexts.ts @@ -1,6 +1,16 @@ import { createContext } from 'react'; -import type { ThemeContextValue, HistoryContextValue, TranscriptionContextValue } from './types'; +import type { + ThemeContextValue, + HistoryContextValue, + TranscriptionContextValue, + TranscriptionStateContextValue, + TranscriptionActionsContextValue, +} from './types'; export const ThemeContext = createContext(null); export const HistoryContext = createContext(null); export const TranscriptionContext = createContext(null); +export const TranscriptionStateContext = createContext(null); +export const TranscriptionActionsContext = createContext( + null +); diff --git a/src/renderer/contexts/hooks.ts b/src/renderer/contexts/hooks.ts index aae54bc..771aec0 100644 --- a/src/renderer/contexts/hooks.ts +++ b/src/renderer/contexts/hooks.ts @@ -1,6 +1,18 @@ import { useContext } from 'react'; -import { ThemeContext, HistoryContext, TranscriptionContext } from './contexts'; -import type { ThemeContextValue, HistoryContextValue, TranscriptionContextValue } from './types'; +import { + ThemeContext, + HistoryContext, + TranscriptionContext, + TranscriptionStateContext, + TranscriptionActionsContext, +} from './contexts'; +import type { + ThemeContextValue, + HistoryContextValue, + TranscriptionContextValue, + TranscriptionStateContextValue, + TranscriptionActionsContextValue, +} from './types'; export function useAppTheme(): ThemeContextValue { const context = useContext(ThemeContext); @@ -25,3 +37,19 @@ export function useAppTranscription(): TranscriptionContextValue { } return context; } + +export function useAppTranscriptionState(): TranscriptionStateContextValue { + const context = useContext(TranscriptionStateContext); + if (!context) { + throw new Error('useAppTranscriptionState must be used within AppProvider'); + } + return context; +} + +export function useAppTranscriptionActions(): TranscriptionActionsContextValue { + const context = useContext(TranscriptionActionsContext); + if (!context) { + throw new Error('useAppTranscriptionActions must be used within AppProvider'); + } + return context; +} diff --git a/src/renderer/contexts/types.ts b/src/renderer/contexts/types.ts index e9f0804..64d2456 100644 --- a/src/renderer/contexts/types.ts +++ b/src/renderer/contexts/types.ts @@ -23,7 +23,7 @@ export interface HistoryContextValue { selectHistoryItem: (item: HistoryItem) => void; } -export interface TranscriptionContextValue { +export interface TranscriptionStateContextValue { selectedFile: SelectedFile | null; settings: TranscriptionSettings; isTranscribing: boolean; @@ -33,7 +33,9 @@ export interface TranscriptionContextValue { error: string | null; modelDownloaded: boolean; copySuccess: boolean; +} +export interface TranscriptionActionsContextValue { setSelectedFile: (file: SelectedFile | null) => void; setSettings: (settings: TranscriptionSettings) => void; setModelDownloaded: (downloaded: boolean) => void; @@ -43,3 +45,6 @@ export interface TranscriptionContextValue { handleSave: (format?: OutputFormat) => Promise; handleCopy: () => Promise; } + +export interface TranscriptionContextValue + extends TranscriptionStateContextValue, TranscriptionActionsContextValue {} From 8165059ae86134b84317c01e54b7daa68262cfc0 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Wed, 17 Dec 2025 23:44:12 -0300 Subject: [PATCH 11/22] refactor: add centralized logger service --- .../DonationSection/DonationSection.tsx | 6 +- .../components/layout/LeftPanel/LeftPanel.tsx | 4 +- .../ui/SystemWarning/SystemWarning.tsx | 14 ++--- .../features/history/hooks/useHistory.ts | 3 +- .../SettingsPanel/SettingsPanel.tsx | 7 ++- src/renderer/services/index.ts | 2 + src/renderer/services/logger.ts | 63 +++++++++++++++++++ src/renderer/test/setup.ts | 10 +++ src/renderer/utils/storage.ts | 6 +- 9 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 src/renderer/services/logger.ts diff --git a/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx b/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx index b060664..3ae84a3 100644 --- a/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx +++ b/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Heart, Coffee } from 'lucide-react'; -import { openExternal, trackEvent } from '../../../../services/electronAPI'; +import { openExternal, trackEvent, logger } from '../../../../services'; import './DonationSection.css'; const PAYPAL_DONATION_URL = 'https://www.paypal.com/donate/?hosted_button_id=HTJXGMEGMWWD6'; @@ -9,12 +9,12 @@ const BUY_ME_A_COFFEE_URL = 'https://www.buymeacoffee.com/pedrovsiqueira'; export function DonationSection(): React.JSX.Element { const handleDonationClick = async (url: string, type: string) => { trackEvent('donation_clicked', { location: 'left_panel', type }).catch((error) => { - console.error(`Failed to track donation click (type: ${type}, url: ${url}):`, error); + logger.error(`Failed to track donation click (type: ${type}, url: ${url}):`, error); }); try { await openExternal(url); } catch (error) { - console.error('Failed to open donation link:', error); + logger.error('Failed to open donation link:', error); } }; diff --git a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx index c9b89e3..41e4458 100644 --- a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx +++ b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx @@ -7,7 +7,7 @@ import { TranscriptionProgress } from './TranscriptionProgress'; import { ErrorMessage } from './ErrorMessage'; import { DonationSection } from './DonationSection'; import { SystemWarning } from '../../ui'; -import { checkFFmpeg } from '../../../services/electronAPI'; +import { checkFFmpeg, logger } from '../../../services'; function LeftPanel(): React.JSX.Element { const { @@ -28,7 +28,7 @@ function LeftPanel(): React.JSX.Element { setIsFFmpegAvailable(available); return available; } catch (error) { - console.error('Failed to check FFmpeg status:', error); + logger.error('Failed to check FFmpeg status:', error); setIsFFmpegAvailable(false); return false; } diff --git a/src/renderer/components/ui/SystemWarning/SystemWarning.tsx b/src/renderer/components/ui/SystemWarning/SystemWarning.tsx index efad6f8..71b326d 100644 --- a/src/renderer/components/ui/SystemWarning/SystemWarning.tsx +++ b/src/renderer/components/ui/SystemWarning/SystemWarning.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Copy, Check, AlertTriangle, RefreshCw } from 'lucide-react'; import './SystemWarning.css'; import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; -import { trackEvent, openExternal } from '../../../services/electronAPI'; +import { trackEvent, openExternal, logger } from '../../../services'; const FFMPEG_DOWNLOAD_URL = 'https://ffmpeg.org/download.html'; const VERIFICATION_RETRY_DELAY_MS = 1000; @@ -36,7 +36,7 @@ function SystemWarning({ onRefresh }: SystemWarningProps): React.JSX.Element { setInstallCommand('brew install ffmpeg'); } } catch (error) { - console.error('Failed to get platform info:', error); + logger.error('Failed to get platform info:', error); } } }; @@ -46,13 +46,13 @@ function SystemWarning({ onRefresh }: SystemWarningProps): React.JSX.Element { const handleCopy = () => { copyToClipboard(installCommand); trackEvent('ffmpeg_install_command_copied', { command: installCommand }).catch((error) => { - console.error('Failed to track copy event:', error); + logger.error('Failed to track copy event:', error); }); }; const handleDownloadLink = async () => { trackEvent('ffmpeg_download_link_clicked').catch((error) => { - console.error( + logger.error( `Failed to track FFmpeg download link click (event: 'ffmpeg_download_link_clicked', url: ${FFMPEG_DOWNLOAD_URL}):`, error ); @@ -60,14 +60,14 @@ function SystemWarning({ onRefresh }: SystemWarningProps): React.JSX.Element { try { await openExternal(FFMPEG_DOWNLOAD_URL); } catch (error) { - console.error('Failed to open link:', error); + logger.error('Failed to open link:', error); } }; const handleRefresh = async () => { setIsChecking(true); trackEvent('ffmpeg_check_again_clicked').catch((error) => { - console.error('Failed to track refresh event:', error); + logger.error('Failed to track refresh event:', error); }); try { const isAvailable = await onRefresh(); @@ -82,7 +82,7 @@ function SystemWarning({ onRefresh }: SystemWarningProps): React.JSX.Element { await onRefresh(); } catch (error) { - console.error('Failed to refresh FFmpeg status:', error); + logger.error('Failed to refresh FFmpeg status:', error); } finally { if (isMounted.current) { setIsChecking(false); diff --git a/src/renderer/features/history/hooks/useHistory.ts b/src/renderer/features/history/hooks/useHistory.ts index ede5d3f..c664487 100644 --- a/src/renderer/features/history/hooks/useHistory.ts +++ b/src/renderer/features/history/hooks/useHistory.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'; import type { HistoryItem } from '../../../types'; import { STORAGE_KEYS } from '../../../utils/storage'; import { APP_CONFIG } from '../../../config'; +import { logger } from '../../../services'; const STORAGE_KEY = STORAGE_KEYS.HISTORY; const MAX_HISTORY_ITEMS = APP_CONFIG.MAX_HISTORY_ITEMS; @@ -23,7 +24,7 @@ const saveHistoryToStorage = (history: HistoryItem[]): void => { const trimmed = history.slice(0, MAX_HISTORY_ITEMS); localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)); } catch (e) { - console.error('Failed to save history:', e); + logger.error('Failed to save history:', e); } }; diff --git a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx index d244336..5b69764 100644 --- a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx +++ b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx @@ -12,6 +12,7 @@ import type { } from '../../../../types'; import { LANGUAGES, QUALITY_STARS } from '../../../../config'; import { DEFAULT_MODELS } from '../../services/modelService'; +import { logger } from '../../../../services'; export interface SettingsPanelProps { settings: TranscriptionSettings; @@ -47,7 +48,7 @@ function SettingsPanel({ setGpuInfo(gpu); } } catch (err) { - console.error('Failed to load model info:', err); + logger.error('Failed to load model info:', err); setModels(DEFAULT_MODELS); } finally { setLoading(false); @@ -108,7 +109,7 @@ function SettingsPanel({ await window.electronAPI?.downloadModel(modelName); await loadModelInfo(); } catch (err) { - console.error('Failed to download model:', err); + logger.error('Failed to download model:', err); } finally { setDownloading(null); } @@ -127,7 +128,7 @@ function SettingsPanel({ } await loadModelInfo(); } catch (err) { - console.error('Failed to delete model:', err); + logger.error('Failed to delete model:', err); window.alert( `Failed to delete model: ${err && typeof err === 'object' && 'message' in err ? err.message : String(err)}` ); diff --git a/src/renderer/services/index.ts b/src/renderer/services/index.ts index 08c5863..3ef7346 100644 --- a/src/renderer/services/index.ts +++ b/src/renderer/services/index.ts @@ -1 +1,3 @@ export * from './electronAPI'; +export { logger } from './logger'; +export type { LogLevel, LogEntry } from './logger'; diff --git a/src/renderer/services/logger.ts b/src/renderer/services/logger.ts new file mode 100644 index 0000000..7a3d82d --- /dev/null +++ b/src/renderer/services/logger.ts @@ -0,0 +1,63 @@ +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +interface LogEntry { + level: LogLevel; + message: string; + data?: unknown; + timestamp: Date; +} + +const isDev = process.env.NODE_ENV === 'development'; + +function formatMessage(level: LogLevel, message: string, data?: unknown): string { + const timestamp = new Date().toISOString(); + const prefix = `[${timestamp}] [${level.toUpperCase()}]`; + return data !== undefined ? `${prefix} ${message}` : `${prefix} ${message}`; +} + +function log(level: LogLevel, message: string, data?: unknown): void { + if (!isDev) return; + + const formattedMessage = formatMessage(level, message, data); + + switch (level) { + case 'debug': + if (data !== undefined) { + console.debug(formattedMessage, data); + } else { + console.debug(formattedMessage); + } + break; + case 'info': + if (data !== undefined) { + console.info(formattedMessage, data); + } else { + console.info(formattedMessage); + } + break; + case 'warn': + if (data !== undefined) { + console.warn(formattedMessage, data); + } else { + console.warn(formattedMessage); + } + break; + case 'error': + if (data !== undefined) { + console.error(formattedMessage, data); + } else { + console.error(formattedMessage); + } + break; + } +} + +export const logger = { + debug: (message: string, data?: unknown): void => log('debug', message, data), + info: (message: string, data?: unknown): void => log('info', message, data), + warn: (message: string, data?: unknown): void => log('warn', message, data), + error: (message: string, data?: unknown): void => log('error', message, data), + isEnabled: (): boolean => isDev, +}; + +export type { LogEntry, LogLevel }; diff --git a/src/renderer/test/setup.ts b/src/renderer/test/setup.ts index 9e70601..feac033 100644 --- a/src/renderer/test/setup.ts +++ b/src/renderer/test/setup.ts @@ -68,3 +68,13 @@ vi.stubGlobal('localStorage', { }); }), }); + +vi.mock('../services/logger', () => ({ + logger: { + debug: vi.fn((...args: unknown[]) => console.debug(...args)), + info: vi.fn((...args: unknown[]) => console.info(...args)), + warn: vi.fn((...args: unknown[]) => console.warn(...args)), + error: vi.fn((...args: unknown[]) => console.error(...args)), + isEnabled: vi.fn(() => true), + }, +})); diff --git a/src/renderer/utils/storage.ts b/src/renderer/utils/storage.ts index 02887ed..a820955 100644 --- a/src/renderer/utils/storage.ts +++ b/src/renderer/utils/storage.ts @@ -1,3 +1,5 @@ +import { logger } from '../services'; + const STORAGE_KEYS = { HISTORY: 'whisperdesk_history', THEME: 'whisperdesk_theme', @@ -23,7 +25,7 @@ export function setStorageItem(key: StorageKey, value: T): boolean { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (e) { - console.error(`Failed to save to localStorage [${key}]:`, e); + logger.error(`Failed to save to localStorage [${key}]:`, e); return false; } } @@ -42,7 +44,7 @@ export function setStorageString(key: StorageKey, value: string): boolean { localStorage.setItem(key, value); return true; } catch (e) { - console.error(`Failed to save to localStorage [${key}]:`, e); + logger.error(`Failed to save to localStorage [${key}]:`, e); return false; } } From 82b06168f47e1adc7e1e5bd2011abdbae56d972e Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Wed, 17 Dec 2025 23:51:31 -0300 Subject: [PATCH 12/22] feat: integrate FFmpeg availability checks into transcription components --- .../components/layout/LeftPanel/LeftPanel.tsx | 8 +++---- .../TranscriptionActions.tsx | 22 +++++++++++++++---- src/renderer/contexts/AppContext.tsx | 8 ++++++- src/renderer/contexts/types.ts | 2 ++ 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx index 41e4458..9067426 100644 --- a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx +++ b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { FileDropZone } from '../../../features/transcription'; import { SettingsPanel } from '../../../features/settings'; import { useAppTranscription } from '../../../contexts'; @@ -14,14 +14,14 @@ function LeftPanel(): React.JSX.Element { selectedFile, settings, isTranscribing, + isFFmpegAvailable, setSelectedFile, setSettings, setModelDownloaded, + setIsFFmpegAvailable, handleFileSelect, } = useAppTranscription(); - const [isFFmpegAvailable, setIsFFmpegAvailable] = useState(null); - const checkStatus = useCallback(async () => { try { const available = await checkFFmpeg(); @@ -32,7 +32,7 @@ function LeftPanel(): React.JSX.Element { setIsFFmpegAvailable(false); return false; } - }, []); + }, [setIsFFmpegAvailable]); useEffect(() => { checkStatus(); diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx b/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx index 5d4dd78..7091f4a 100644 --- a/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx +++ b/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx @@ -3,8 +3,22 @@ import { Zap } from 'lucide-react'; import { useAppTranscription } from '../../../../contexts'; export function TranscriptionActions(): React.JSX.Element { - const { selectedFile, isTranscribing, modelDownloaded, handleTranscribe, handleCancel } = - useAppTranscription(); + const { + selectedFile, + isTranscribing, + modelDownloaded, + isFFmpegAvailable, + handleTranscribe, + handleCancel, + } = useAppTranscription(); + + const canTranscribe = selectedFile && modelDownloaded && isFFmpegAvailable === true; + + const getDisabledReason = (): string => { + if (!isFFmpegAvailable) return 'Please install FFmpeg first'; + if (!modelDownloaded) return 'Please download the selected model first'; + return ''; + }; return (
@@ -12,9 +26,9 @@ export function TranscriptionActions(): React.JSX.Element { diff --git a/src/renderer/contexts/AppContext.tsx b/src/renderer/contexts/AppContext.tsx index 3aac94e..b6d9b51 100644 --- a/src/renderer/contexts/AppContext.tsx +++ b/src/renderer/contexts/AppContext.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, type ReactNode } from 'react'; +import React, { useState, useCallback, useMemo, type ReactNode } from 'react'; import { useTranscription } from '../features/transcription'; import { useHistory } from '../features/history'; import { useTheme, useCopyToClipboard, useElectronMenu } from '../hooks'; @@ -128,6 +128,8 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { ] ); + const [isFFmpegAvailable, setIsFFmpegAvailable] = useState(null); + const transcriptionStateValue = useMemo( () => ({ selectedFile, @@ -139,6 +141,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { error, modelDownloaded, copySuccess, + isFFmpegAvailable, }), [ selectedFile, @@ -150,6 +153,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { error, modelDownloaded, copySuccess, + isFFmpegAvailable, ] ); @@ -158,6 +162,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { setSelectedFile, setSettings, setModelDownloaded, + setIsFFmpegAvailable, handleFileSelect, handleTranscribe, handleCancel, @@ -168,6 +173,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { setSelectedFile, setSettings, setModelDownloaded, + setIsFFmpegAvailable, handleFileSelect, handleTranscribe, handleCancel, diff --git a/src/renderer/contexts/types.ts b/src/renderer/contexts/types.ts index 64d2456..a61bd3e 100644 --- a/src/renderer/contexts/types.ts +++ b/src/renderer/contexts/types.ts @@ -33,12 +33,14 @@ export interface TranscriptionStateContextValue { error: string | null; modelDownloaded: boolean; copySuccess: boolean; + isFFmpegAvailable: boolean | null; } export interface TranscriptionActionsContextValue { setSelectedFile: (file: SelectedFile | null) => void; setSettings: (settings: TranscriptionSettings) => void; setModelDownloaded: (downloaded: boolean) => void; + setIsFFmpegAvailable: (available: boolean | null) => void; handleFileSelect: (file: SelectedFile) => void; handleTranscribe: () => Promise; handleCancel: () => Promise; From 9f517c15c492e9a609f7f8fd68fb48629d5964ec Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 18 Dec 2025 00:17:53 -0300 Subject: [PATCH 13/22] feat: refactor transcription and menu handling to use centralized electronAPI services --- .../__tests__/useTranscription.test.ts | 2 +- .../transcription/hooks/useTranscription.ts | 28 +++++++++++-------- src/renderer/hooks/useElectronMenu.ts | 24 ++++++++++------ src/renderer/services/electronAPI.ts | 28 +++++++++++++++++++ 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/src/renderer/features/transcription/__tests__/useTranscription.test.ts b/src/renderer/features/transcription/__tests__/useTranscription.test.ts index 09e31c5..2a6e522 100644 --- a/src/renderer/features/transcription/__tests__/useTranscription.test.ts +++ b/src/renderer/features/transcription/__tests__/useTranscription.test.ts @@ -552,7 +552,7 @@ describe('useTranscription', () => { expect(result.current.isTranscribing).toBe(false); }); - expect(result.current.error).toContain('No response'); + expect(result.current.error).toContain('no output'); }); it('should handle transcription progress updates', async () => { diff --git a/src/renderer/features/transcription/hooks/useTranscription.ts b/src/renderer/features/transcription/hooks/useTranscription.ts index 9cf93d1..99a901a 100644 --- a/src/renderer/features/transcription/hooks/useTranscription.ts +++ b/src/renderer/features/transcription/hooks/useTranscription.ts @@ -7,6 +7,14 @@ import type { OutputFormat, } from '../../../types'; import { APP_CONFIG } from '../../../config'; +import { + openFileDialog, + getFileInfo, + startTranscription, + cancelTranscription, + onTranscriptionProgress, + saveFile, +} from '../../../services/electronAPI'; interface UseTranscriptionOptions { onHistoryAdd?: (item: HistoryItem) => void; @@ -75,13 +83,11 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran ); useEffect(() => { - const unsubscribe = window.electronAPI?.onTranscriptionProgress( - (data: TranscriptionProgress) => { - setProgress(data); - } - ); + const unsubscribe = onTranscriptionProgress((data: TranscriptionProgress) => { + setProgress(data); + }); return () => { - unsubscribe?.(); + unsubscribe(); }; }, []); @@ -104,9 +110,9 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran ); const handleFileSelectFromMenu = useCallback(async (): Promise => { - const filePath = await window.electronAPI?.openFile(); + const filePath = await openFileDialog(); if (filePath) { - const fileInfo = await window.electronAPI?.getFileInfo(filePath); + const fileInfo = await getFileInfo(filePath); if (fileInfo) { handleFileSelect(fileInfo); } @@ -125,7 +131,7 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran const startTime = Date.now(); try { - const result = await window.electronAPI?.startTranscription({ + const result = await startTranscription({ filePath: selectedFile.path, model: settings.model, language: settings.language, @@ -173,7 +179,7 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran }, [selectedFile, settings, onHistoryAdd, scheduleProgressReset, clearProgressMessageTimeout]); const handleCancel = useCallback(async (): Promise => { - await window.electronAPI?.cancelTranscription(); + await cancelTranscription(); clearProgressMessageTimeout(); setIsTranscribing(false); setProgress({ percent: 0, status: 'Cancelled' }); @@ -218,7 +224,7 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran // For docx, pdf, md formats, the main process will handle the conversion - const result = await window.electronAPI?.saveFile({ + const result = await saveFile({ defaultName: `${fileName}.${format}`, content, format, diff --git a/src/renderer/hooks/useElectronMenu.ts b/src/renderer/hooks/useElectronMenu.ts index 98874ce..09c4cb5 100644 --- a/src/renderer/hooks/useElectronMenu.ts +++ b/src/renderer/hooks/useElectronMenu.ts @@ -1,5 +1,13 @@ import { useEffect, useRef } from 'react'; import type { Unsubscribe } from '../types'; +import { + onMenuOpenFile, + onMenuSaveFile, + onMenuCopyTranscription, + onMenuStartTranscription, + onMenuCancelTranscription, + onMenuToggleHistory, +} from '../services/electronAPI'; export interface ElectronMenuHandlers { onOpenFile?: () => void; @@ -18,46 +26,46 @@ export function useElectronMenu(handlers: ElectronMenuHandlers): void { }); useEffect(() => { - const unsubscribers: (Unsubscribe | undefined)[] = []; + const unsubscribers: Unsubscribe[] = []; unsubscribers.push( - window.electronAPI?.onMenuOpenFile(() => { + onMenuOpenFile(() => { handlersRef.current.onOpenFile?.(); }) ); unsubscribers.push( - window.electronAPI?.onMenuSaveFile(() => { + onMenuSaveFile(() => { handlersRef.current.onSaveFile?.(); }) ); unsubscribers.push( - window.electronAPI?.onMenuCopyTranscription(() => { + onMenuCopyTranscription(() => { handlersRef.current.onCopyTranscription?.(); }) ); unsubscribers.push( - window.electronAPI?.onMenuStartTranscription(() => { + onMenuStartTranscription(() => { handlersRef.current.onStartTranscription?.(); }) ); unsubscribers.push( - window.electronAPI?.onMenuCancelTranscription(() => { + onMenuCancelTranscription(() => { handlersRef.current.onCancelTranscription?.(); }) ); unsubscribers.push( - window.electronAPI?.onMenuToggleHistory(() => { + onMenuToggleHistory(() => { handlersRef.current.onToggleHistory?.(); }) ); return () => { - unsubscribers.forEach((unsub) => unsub?.()); + unsubscribers.forEach((unsub) => unsub()); }; }, []); } diff --git a/src/renderer/services/electronAPI.ts b/src/renderer/services/electronAPI.ts index d0fab5b..9b495b9 100644 --- a/src/renderer/services/electronAPI.ts +++ b/src/renderer/services/electronAPI.ts @@ -28,6 +28,10 @@ export async function getFileInfo(filePath: string): Promise { const result = await window.electronAPI?.saveFile(options); return result ?? { success: false, error: 'Electron API not available' }; @@ -123,3 +127,27 @@ export function onUpdateStatus(callback: (status: UpdateStatus) => void): Unsubs export async function openExternal(url: string): Promise { await window.electronAPI?.openExternal(url); } + +export function onMenuOpenFile(callback: () => void): Unsubscribe { + return window.electronAPI?.onMenuOpenFile(callback) ?? (() => {}); +} + +export function onMenuSaveFile(callback: () => void): Unsubscribe { + return window.electronAPI?.onMenuSaveFile(callback) ?? (() => {}); +} + +export function onMenuCopyTranscription(callback: () => void): Unsubscribe { + return window.electronAPI?.onMenuCopyTranscription(callback) ?? (() => {}); +} + +export function onMenuStartTranscription(callback: () => void): Unsubscribe { + return window.electronAPI?.onMenuStartTranscription(callback) ?? (() => {}); +} + +export function onMenuCancelTranscription(callback: () => void): Unsubscribe { + return window.electronAPI?.onMenuCancelTranscription(callback) ?? (() => {}); +} + +export function onMenuToggleHistory(callback: () => void): Unsubscribe { + return window.electronAPI?.onMenuToggleHistory(callback) ?? (() => {}); +} From 2eeba4cd16d852edcd2e864448ffdd78de78a1c5 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 18 Dec 2025 00:20:03 -0300 Subject: [PATCH 14/22] feat: implement useFFmpegStatus hook --- .../components/layout/LeftPanel/LeftPanel.tsx | 28 +++----------- .../TranscriptionActions.tsx | 18 ++++----- .../LeftPanel/TranscriptionActions/index.ts | 1 + src/renderer/contexts/AppContext.tsx | 8 +--- src/renderer/contexts/types.ts | 2 - src/renderer/hooks/index.ts | 3 ++ src/renderer/hooks/useFFmpegStatus.ts | 38 +++++++++++++++++++ 7 files changed, 58 insertions(+), 40 deletions(-) create mode 100644 src/renderer/hooks/useFFmpegStatus.ts diff --git a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx index 9067426..3318876 100644 --- a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx +++ b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx @@ -1,51 +1,35 @@ -import React, { useEffect, useCallback } from 'react'; +import React from 'react'; import { FileDropZone } from '../../../features/transcription'; import { SettingsPanel } from '../../../features/settings'; import { useAppTranscription } from '../../../contexts'; +import { useFFmpegStatus } from '../../../hooks'; import { TranscriptionActions } from './TranscriptionActions'; import { TranscriptionProgress } from './TranscriptionProgress'; import { ErrorMessage } from './ErrorMessage'; import { DonationSection } from './DonationSection'; import { SystemWarning } from '../../ui'; -import { checkFFmpeg, logger } from '../../../services'; function LeftPanel(): React.JSX.Element { const { selectedFile, settings, isTranscribing, - isFFmpegAvailable, setSelectedFile, setSettings, setModelDownloaded, - setIsFFmpegAvailable, handleFileSelect, } = useAppTranscription(); - const checkStatus = useCallback(async () => { - try { - const available = await checkFFmpeg(); - setIsFFmpegAvailable(available); - return available; - } catch (error) { - logger.error('Failed to check FFmpeg status:', error); - setIsFFmpegAvailable(false); - return false; - } - }, [setIsFFmpegAvailable]); - - useEffect(() => { - checkStatus(); - }, [checkStatus]); + const { isFFmpegAvailable, isChecking, recheckStatus } = useFFmpegStatus(); return (
- {isFFmpegAvailable === null && ( + {isChecking && isFFmpegAvailable === null && (
Checking system requirements...
)} - {isFFmpegAvailable === false && } + {isFFmpegAvailable === false && } - + diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx b/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx index 7091f4a..44b1fec 100644 --- a/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx +++ b/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx @@ -2,15 +2,15 @@ import React from 'react'; import { Zap } from 'lucide-react'; import { useAppTranscription } from '../../../../contexts'; -export function TranscriptionActions(): React.JSX.Element { - const { - selectedFile, - isTranscribing, - modelDownloaded, - isFFmpegAvailable, - handleTranscribe, - handleCancel, - } = useAppTranscription(); +export interface TranscriptionActionsProps { + isFFmpegAvailable: boolean | null; +} + +export function TranscriptionActions({ + isFFmpegAvailable, +}: TranscriptionActionsProps): React.JSX.Element { + const { selectedFile, isTranscribing, modelDownloaded, handleTranscribe, handleCancel } = + useAppTranscription(); const canTranscribe = selectedFile && modelDownloaded && isFFmpegAvailable === true; diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionActions/index.ts b/src/renderer/components/layout/LeftPanel/TranscriptionActions/index.ts index 051556a..4b974a8 100644 --- a/src/renderer/components/layout/LeftPanel/TranscriptionActions/index.ts +++ b/src/renderer/components/layout/LeftPanel/TranscriptionActions/index.ts @@ -1 +1,2 @@ export { TranscriptionActions } from './TranscriptionActions'; +export type { TranscriptionActionsProps } from './TranscriptionActions'; diff --git a/src/renderer/contexts/AppContext.tsx b/src/renderer/contexts/AppContext.tsx index b6d9b51..3aac94e 100644 --- a/src/renderer/contexts/AppContext.tsx +++ b/src/renderer/contexts/AppContext.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useMemo, type ReactNode } from 'react'; +import React, { useCallback, useMemo, type ReactNode } from 'react'; import { useTranscription } from '../features/transcription'; import { useHistory } from '../features/history'; import { useTheme, useCopyToClipboard, useElectronMenu } from '../hooks'; @@ -128,8 +128,6 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { ] ); - const [isFFmpegAvailable, setIsFFmpegAvailable] = useState(null); - const transcriptionStateValue = useMemo( () => ({ selectedFile, @@ -141,7 +139,6 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { error, modelDownloaded, copySuccess, - isFFmpegAvailable, }), [ selectedFile, @@ -153,7 +150,6 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { error, modelDownloaded, copySuccess, - isFFmpegAvailable, ] ); @@ -162,7 +158,6 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { setSelectedFile, setSettings, setModelDownloaded, - setIsFFmpegAvailable, handleFileSelect, handleTranscribe, handleCancel, @@ -173,7 +168,6 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { setSelectedFile, setSettings, setModelDownloaded, - setIsFFmpegAvailable, handleFileSelect, handleTranscribe, handleCancel, diff --git a/src/renderer/contexts/types.ts b/src/renderer/contexts/types.ts index a61bd3e..64d2456 100644 --- a/src/renderer/contexts/types.ts +++ b/src/renderer/contexts/types.ts @@ -33,14 +33,12 @@ export interface TranscriptionStateContextValue { error: string | null; modelDownloaded: boolean; copySuccess: boolean; - isFFmpegAvailable: boolean | null; } export interface TranscriptionActionsContextValue { setSelectedFile: (file: SelectedFile | null) => void; setSettings: (settings: TranscriptionSettings) => void; setModelDownloaded: (downloaded: boolean) => void; - setIsFFmpegAvailable: (available: boolean | null) => void; handleFileSelect: (file: SelectedFile) => void; handleTranscribe: () => Promise; handleCancel: () => Promise; diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index 63bb717..fa51b48 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -6,3 +6,6 @@ export type { ElectronMenuHandlers } from './useElectronMenu'; export { useCopyToClipboard } from './useCopyToClipboard'; export type { UseCopyToClipboardReturn } from './useCopyToClipboard'; + +export { useFFmpegStatus } from './useFFmpegStatus'; +export type { UseFFmpegStatusReturn } from './useFFmpegStatus'; diff --git a/src/renderer/hooks/useFFmpegStatus.ts b/src/renderer/hooks/useFFmpegStatus.ts new file mode 100644 index 0000000..355072a --- /dev/null +++ b/src/renderer/hooks/useFFmpegStatus.ts @@ -0,0 +1,38 @@ +import { useState, useEffect, useCallback } from 'react'; +import { checkFFmpeg, logger } from '../services'; + +export interface UseFFmpegStatusReturn { + isFFmpegAvailable: boolean | null; + isChecking: boolean; + recheckStatus: () => Promise; +} + +export function useFFmpegStatus(): UseFFmpegStatusReturn { + const [isFFmpegAvailable, setIsFFmpegAvailable] = useState(null); + const [isChecking, setIsChecking] = useState(true); + + const recheckStatus = useCallback(async (): Promise => { + setIsChecking(true); + try { + const available = await checkFFmpeg(); + setIsFFmpegAvailable(available); + return available; + } catch (error) { + logger.error('Failed to check FFmpeg status:', error); + setIsFFmpegAvailable(false); + return false; + } finally { + setIsChecking(false); + } + }, []); + + useEffect(() => { + recheckStatus(); + }, [recheckStatus]); + + return { + isFFmpegAvailable, + isChecking, + recheckStatus, + }; +} From 8b6f31d368225026497d674d7532b62c9ecc0cb5 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 18 Dec 2025 00:27:45 -0300 Subject: [PATCH 15/22] feat: refactor SystemWarning and SettingsPanel to use updated service methods --- .../ui/SystemWarning/SystemWarning.tsx | 24 +++++------ .../SettingsPanel/SettingsPanel.tsx | 42 ++++++++++--------- .../components/FileDropZone/FileDropZone.tsx | 5 ++- 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/renderer/components/ui/SystemWarning/SystemWarning.tsx b/src/renderer/components/ui/SystemWarning/SystemWarning.tsx index 71b326d..6669ba2 100644 --- a/src/renderer/components/ui/SystemWarning/SystemWarning.tsx +++ b/src/renderer/components/ui/SystemWarning/SystemWarning.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Copy, Check, AlertTriangle, RefreshCw } from 'lucide-react'; import './SystemWarning.css'; import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; -import { trackEvent, openExternal, logger } from '../../../services'; +import { trackEvent, openExternal, getAppInfo, logger } from '../../../services'; const FFMPEG_DOWNLOAD_URL = 'https://ffmpeg.org/download.html'; const VERIFICATION_RETRY_DELAY_MS = 1000; @@ -25,19 +25,17 @@ function SystemWarning({ onRefresh }: SystemWarningProps): React.JSX.Element { useEffect(() => { const getPlatform = async () => { - if (window.electronAPI?.getAppInfo) { - try { - const appInfo = await window.electronAPI.getAppInfo(); - if (appInfo.platform === 'win32') { - setInstallCommand('winget install ffmpeg'); - } else if (appInfo.platform === 'linux') { - setInstallCommand('sudo apt install ffmpeg'); - } else { - setInstallCommand('brew install ffmpeg'); - } - } catch (error) { - logger.error('Failed to get platform info:', error); + try { + const appInfo = await getAppInfo(); + if (appInfo.platform === 'win32') { + setInstallCommand('winget install ffmpeg'); + } else if (appInfo.platform === 'linux') { + setInstallCommand('sudo apt install ffmpeg'); + } else { + setInstallCommand('brew install ffmpeg'); } + } catch (error) { + logger.error('Failed to get platform info:', error); } }; getPlatform(); diff --git a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx index 5b69764..da95aa9 100644 --- a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx +++ b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx @@ -12,7 +12,14 @@ import type { } from '../../../../types'; import { LANGUAGES, QUALITY_STARS } from '../../../../config'; import { DEFAULT_MODELS } from '../../services/modelService'; -import { logger } from '../../../../services'; +import { + listModels, + getGpuStatus, + onModelDownloadProgress, + downloadModel, + deleteModel, + logger, +} from '../../../../services'; export interface SettingsPanelProps { settings: TranscriptionSettings; @@ -36,10 +43,7 @@ function SettingsPanel({ const loadModelInfo = async (): Promise => { try { setLoading(true); - const [modelList, gpu] = await Promise.all([ - window.electronAPI?.listModels(), - window.electronAPI?.getGpuStatus(), - ]); + const [modelList, gpu] = await Promise.all([listModels(), getGpuStatus()]); if (modelList?.models) { setModels(modelList.models); @@ -58,22 +62,20 @@ function SettingsPanel({ useEffect(() => { loadModelInfo(); - const unsubscribe = window.electronAPI?.onModelDownloadProgress?.( - (data: ModelDownloadProgress) => { - setDownloadProgress(data); - if (data.status === 'complete') { - setDownloading(null); - setDownloadProgress(null); - loadModelInfo(); - } else if (data.status === 'error') { - setDownloading(null); - setDownloadProgress(null); - } + const unsubscribe = onModelDownloadProgress((data: ModelDownloadProgress) => { + setDownloadProgress(data); + if (data.status === 'complete') { + setDownloading(null); + setDownloadProgress(null); + loadModelInfo(); + } else if (data.status === 'error') { + setDownloading(null); + setDownloadProgress(null); } - ); + }); return () => { - unsubscribe?.(); + unsubscribe(); }; }, []); @@ -106,7 +108,7 @@ function SettingsPanel({ const handleDownloadModel = async (modelName: string): Promise => { try { setDownloading(modelName); - await window.electronAPI?.downloadModel(modelName); + await downloadModel(modelName); await loadModelInfo(); } catch (err) { logger.error('Failed to download model:', err); @@ -121,7 +123,7 @@ function SettingsPanel({ } try { setLoading(true); - const result = await window.electronAPI?.deleteModel(modelName); + const result = await deleteModel(modelName); if (!result?.success) { window.alert(`Failed to delete model: ${result?.error || 'Unknown error'}`); return; diff --git a/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx b/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx index 5113200..a781638 100644 --- a/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx +++ b/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx @@ -2,6 +2,7 @@ import React, { useCallback, type DragEvent, type KeyboardEvent } from 'react'; import { X } from 'lucide-react'; import { isValidMediaFile, formatFileSize } from '../../../../utils'; import type { SelectedFile } from '../../../../types'; +import { openFileDialog, getPathForFile } from '../../../../services/electronAPI'; import './FileDropZone.css'; export interface FileDropZoneProps { @@ -20,7 +21,7 @@ function FileDropZone({ const handleClick = async (): Promise => { if (disabled) return; - const filePath = await window.electronAPI?.openFile(); + const filePath = await openFileDialog(); if (filePath) { const fileName = filePath.split('/').pop(); if (fileName && isValidMediaFile(fileName)) { @@ -36,7 +37,7 @@ function FileDropZone({ const file = e.dataTransfer.files[0]; if (file && isValidMediaFile(file.name)) { - const filePath = window.electronAPI?.getPathForFile(file); + const filePath = getPathForFile(file); if (filePath) { onFileSelect({ path: filePath, name: file.name }); } From 9dde448e68e3836483e1b6a06a5024e05cf4589e Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 18 Dec 2025 00:34:28 -0300 Subject: [PATCH 16/22] feat: enhance electronAPI tests with additional functionality checks and event tracking --- .../services/__tests__/electronAPI.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/renderer/services/__tests__/electronAPI.test.ts b/src/renderer/services/__tests__/electronAPI.test.ts index 0f932ca..c634372 100644 --- a/src/renderer/services/__tests__/electronAPI.test.ts +++ b/src/renderer/services/__tests__/electronAPI.test.ts @@ -3,6 +3,7 @@ import { isElectronAvailable, openFileDialog, getFileInfo, + getPathForFile, saveFile, startTranscription, cancelTranscription, @@ -12,8 +13,21 @@ import { deleteModel, onModelDownloadProgress, getGpuStatus, + checkFFmpeg, getAppInfo, getMemoryUsage, + trackEvent, + checkForUpdates, + downloadUpdate, + installUpdate, + onUpdateStatus, + openExternal, + onMenuOpenFile, + onMenuSaveFile, + onMenuCopyTranscription, + onMenuStartTranscription, + onMenuCancelTranscription, + onMenuToggleHistory, } from '@/services'; import type { ElectronAPI } from '@/types/electron'; import { createFullElectronAPIMock } from '@/test/electronAPIMocks'; @@ -37,6 +51,9 @@ describe('electronAPI wrapper', () => { const info = await getFileInfo('/missing'); expect(info).toBeNull(); + const pathForFile = getPathForFile(new File([''], 'test.mp3')); + expect(pathForFile).toBeUndefined(); + const saveRes = await saveFile({ defaultName: 'x.txt', content: 'c', format: 'txt' }); expect(saveRes.success).toBe(false); @@ -71,11 +88,54 @@ describe('electronAPI wrapper', () => { const gpu = await getGpuStatus(); expect(gpu.available).toBe(false); + const ffmpeg = await checkFFmpeg(); + expect(ffmpeg).toBe(false); + const appInfo = await getAppInfo(); expect(appInfo.isDev).toBe(true); const mem = await getMemoryUsage(); expect(mem.heapUsed).toBe(0); + + await expect(trackEvent('test_event', { prop: 'value' })).resolves.toBeUndefined(); + + const updateCheck = await checkForUpdates(); + expect(updateCheck.success).toBe(false); + + const updateDownload = await downloadUpdate(); + expect(updateDownload.success).toBe(false); + + expect(() => installUpdate()).not.toThrow(); + + const unsubscribeUpdate = onUpdateStatus(() => {}); + expect(typeof unsubscribeUpdate).toBe('function'); + unsubscribeUpdate(); + + await expect(openExternal('https://example.com')).resolves.toBeUndefined(); + + const unsubscribeMenuOpen = onMenuOpenFile(() => {}); + expect(typeof unsubscribeMenuOpen).toBe('function'); + unsubscribeMenuOpen(); + + const unsubscribeMenuSave = onMenuSaveFile(() => {}); + expect(typeof unsubscribeMenuSave).toBe('function'); + unsubscribeMenuSave(); + + const unsubscribeMenuCopy = onMenuCopyTranscription(() => {}); + expect(typeof unsubscribeMenuCopy).toBe('function'); + unsubscribeMenuCopy(); + + const unsubscribeMenuStart = onMenuStartTranscription(() => {}); + expect(typeof unsubscribeMenuStart).toBe('function'); + unsubscribeMenuStart(); + + const unsubscribeMenuCancel = onMenuCancelTranscription(() => {}); + expect(typeof unsubscribeMenuCancel).toBe('function'); + unsubscribeMenuCancel(); + + const unsubscribeMenuHistory = onMenuToggleHistory(() => {}); + expect(typeof unsubscribeMenuHistory).toBe('function'); + unsubscribeMenuHistory(); }); it('delegates to underlying window.electronAPI when available', async () => { @@ -88,6 +148,10 @@ describe('electronAPI wrapper', () => { await getFileInfo('/path/file.mp3'); expect(api.getFileInfo).toHaveBeenCalledWith('/path/file.mp3'); + const testFile = new File([''], 'test.mp3'); + getPathForFile(testFile); + expect(api.getPathForFile).toHaveBeenCalledWith(testFile); + await saveFile({ defaultName: 'x.txt', content: 'c', format: 'txt' }); expect(api.saveFile).toHaveBeenCalled(); @@ -120,10 +184,49 @@ describe('electronAPI wrapper', () => { await getGpuStatus(); expect(api.getGpuStatus).toHaveBeenCalled(); + await checkFFmpeg(); + expect(api.checkFFmpeg).toHaveBeenCalled(); + await getAppInfo(); expect(api.getAppInfo).toHaveBeenCalled(); await getMemoryUsage(); expect(api.getMemoryUsage).toHaveBeenCalled(); + + await trackEvent('test_event', { prop: 'value' }); + expect(api.trackEvent).toHaveBeenCalledWith('test_event', { prop: 'value' }); + + await checkForUpdates(); + expect(api.checkForUpdates).toHaveBeenCalled(); + + await downloadUpdate(); + expect(api.downloadUpdate).toHaveBeenCalled(); + + installUpdate(); + expect(api.installUpdate).toHaveBeenCalled(); + + onUpdateStatus(() => {}); + expect(api.onUpdateStatus).toHaveBeenCalled(); + + await openExternal('https://example.com'); + expect(api.openExternal).toHaveBeenCalledWith('https://example.com'); + + onMenuOpenFile(() => {}); + expect(api.onMenuOpenFile).toHaveBeenCalled(); + + onMenuSaveFile(() => {}); + expect(api.onMenuSaveFile).toHaveBeenCalled(); + + onMenuCopyTranscription(() => {}); + expect(api.onMenuCopyTranscription).toHaveBeenCalled(); + + onMenuStartTranscription(() => {}); + expect(api.onMenuStartTranscription).toHaveBeenCalled(); + + onMenuCancelTranscription(() => {}); + expect(api.onMenuCancelTranscription).toHaveBeenCalled(); + + onMenuToggleHistory(() => {}); + expect(api.onMenuToggleHistory).toHaveBeenCalled(); }); }); From 6bcb9fa2e67d6b135fd228e6c52c2fafaef29423 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 18 Dec 2025 01:29:38 -0300 Subject: [PATCH 17/22] feat: add debug logs modal and integrate logging functionality across components --- src/main/ipc/index.ts | 1 + src/main/services/whisper.ts | 6 +- .../components/layout/AppHeader/AppHeader.tsx | 94 ++++++--- .../ui/DebugLogsModal/DebugLogsModal.css | 196 ++++++++++++++++++ .../ui/DebugLogsModal/DebugLogsModal.tsx | 143 +++++++++++++ .../components/ui/DebugLogsModal/index.ts | 2 + .../ui/ErrorBoundary/ErrorBoundary.tsx | 10 +- .../__tests__/SystemWarning.test.tsx | 16 +- src/renderer/components/ui/index.ts | 2 + .../transcription/hooks/useTranscription.ts | 50 ++++- src/renderer/hooks/index.ts | 3 + src/renderer/hooks/useDebugLogs.ts | 122 +++++++++++ src/renderer/services/logger.ts | 34 +++ src/shared/types.ts | 1 + 14 files changed, 636 insertions(+), 44 deletions(-) create mode 100644 src/renderer/components/ui/DebugLogsModal/DebugLogsModal.css create mode 100644 src/renderer/components/ui/DebugLogsModal/DebugLogsModal.tsx create mode 100644 src/renderer/components/ui/DebugLogsModal/index.ts create mode 100644 src/renderer/hooks/useDebugLogs.ts diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index fec39b6..6628154 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -174,6 +174,7 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null) { isDev: !app.isPackaged, version: app.getVersion(), platform: process.platform, + osVersion: process.getSystemVersion(), }; }); diff --git a/src/main/services/whisper.ts b/src/main/services/whisper.ts index 0ed9d8c..df9b2e5 100644 --- a/src/main/services/whisper.ts +++ b/src/main/services/whisper.ts @@ -580,7 +580,11 @@ export function transcribe( vttPath, stdoutLength: stdout.length, }); - reject(new Error('Transcription produced no output')); + reject( + new Error( + 'Transcription produced no output. The audio file might be empty, silent, or contain no valid audio stream.' + ) + ); return; } diff --git a/src/renderer/components/layout/AppHeader/AppHeader.tsx b/src/renderer/components/layout/AppHeader/AppHeader.tsx index 8645338..15025ad 100644 --- a/src/renderer/components/layout/AppHeader/AppHeader.tsx +++ b/src/renderer/components/layout/AppHeader/AppHeader.tsx @@ -1,46 +1,76 @@ import React from 'react'; -import { Moon, Sun, History } from 'lucide-react'; +import { Moon, Sun, History, Terminal } from 'lucide-react'; import { useAppTheme, useAppHistory } from '../../../contexts'; +import { useDebugLogs } from '../../../hooks'; +import { DebugLogsModal } from '../../ui/DebugLogsModal'; import appIcon from '../../../assets/icon.png'; function AppHeader(): React.JSX.Element { const { theme, toggleTheme } = useAppTheme(); const { history, showHistory, toggleHistory } = useAppHistory(); + const { + logs, + isOpen: isDebugLogsOpen, + openModal: openDebugLogs, + closeModal: closeDebugLogs, + copyLogs, + copyLogsWithSystemInfo, + clearLogs, + } = useDebugLogs(); return ( -
-
-
- WhisperDesk -
-

WhisperDesk

-

Transcribe audio & video with AI

+ <> +
+
+
+ WhisperDesk +
+

WhisperDesk

+

Transcribe audio & video with AI

+
+
+
+ + +
-
- - -
-
-
+ + + + ); } diff --git a/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.css b/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.css new file mode 100644 index 0000000..407ae2e --- /dev/null +++ b/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.css @@ -0,0 +1,196 @@ +.debug-logs-overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); + animation: fadeIn 0.2s ease-out; +} + +.debug-logs-modal { + background-color: var(--bg-primary); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: min(90vw, 800px); + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border); + animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.debug-logs-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + background-color: var(--bg-secondary); +} + +.debug-logs-header h2 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.debug-logs-header .log-count { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 400; +} + +.debug-logs-close { + background: none; + border: none; + padding: 8px; + cursor: pointer; + color: var(--text-secondary); + border-radius: var(--radius-sm); + transition: all var(--transition-base); + display: flex; + align-items: center; + justify-content: center; +} + +.debug-logs-close:hover { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.debug-logs-content { + flex: 1; + overflow-y: auto; + padding: 16px; + font-family: var(--font-mono, 'SF Mono', 'Monaco', 'Consolas', monospace); + font-size: 0.8125rem; + line-height: 1.5; +} + +.debug-logs-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px; + color: var(--text-secondary); + text-align: center; +} + +.debug-logs-empty svg { + margin-bottom: 12px; + opacity: 0.5; +} + +.log-entry { + padding: 4px 8px; + border-radius: 4px; + margin-bottom: 2px; + white-space: pre-wrap; + word-break: break-word; +} + +.log-entry.level-debug { + color: var(--text-secondary); +} + +.log-entry.level-info { + color: var(--accent); +} + +.log-entry.level-warn { + color: var(--warning); + background-color: var(--warning-surface-dim); +} + +.log-entry.level-error { + color: var(--danger); + background-color: var(--danger-light); +} + +.debug-logs-footer { + display: flex; + gap: 8px; + padding: 16px 20px; + border-top: 1px solid var(--border); + background-color: var(--bg-secondary); +} + +.debug-logs-footer button { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-base); +} + +.btn-copy-logs { + background-color: var(--accent); + color: white; + border: none; +} + +.btn-copy-logs:hover { + background-color: var(--accent-hover); +} + +.btn-copy-with-info { + background-color: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.btn-copy-with-info:hover { + background-color: var(--bg-hover); +} + +.btn-clear-logs { + background-color: transparent; + color: var(--danger); + border: 1px solid var(--danger); + flex: 0.5; +} + +.btn-clear-logs:hover { + background-color: var(--danger-light); +} + +.btn-copy-logs.copied, +.btn-copy-with-info.copied { + background-color: var(--success); + color: white; + border-color: transparent; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideIn { + from { + transform: translateY(10px) scale(0.98); + opacity: 0; + } + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} diff --git a/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.tsx b/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.tsx new file mode 100644 index 0000000..9036d9a --- /dev/null +++ b/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.tsx @@ -0,0 +1,143 @@ +import React, { useEffect, useCallback } from 'react'; +import { X, Terminal, Copy, Clipboard, Trash2 } from 'lucide-react'; +import type { LogEntry } from '../../../services/logger'; +import './DebugLogsModal.css'; + +interface DebugLogsModalProps { + isOpen: boolean; + logs: LogEntry[]; + onClose: () => void; + onCopyLogs: () => Promise; + onCopyLogsWithSystemInfo: () => Promise; + onClearLogs: () => void; +} + +function formatLogEntry(entry: LogEntry): string { + const timestamp = entry.timestamp.toISOString().substring(11, 23); + const level = entry.level.toUpperCase().padEnd(5); + const data = entry.data !== undefined ? ` | ${JSON.stringify(entry.data)}` : ''; + return `[${timestamp}] [${level}] ${entry.message}${data}`; +} + +function DebugLogsModal({ + isOpen, + logs, + onClose, + onCopyLogs, + onCopyLogsWithSystemInfo, + onClearLogs, +}: DebugLogsModalProps): React.JSX.Element | null { + const [copyState, setCopyState] = React.useState<'idle' | 'logs' | 'info'>('idle'); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }, + [onClose] + ); + + useEffect(() => { + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + } + return undefined; + }, [isOpen, handleKeyDown]); + + const handleCopyLogs = async () => { + const success = await onCopyLogs(); + if (success) { + setCopyState('logs'); + setTimeout(() => setCopyState('idle'), 2000); + } + }; + + const handleCopyWithInfo = async () => { + const success = await onCopyLogsWithSystemInfo(); + if (success) { + setCopyState('info'); + setTimeout(() => setCopyState('idle'), 2000); + } + }; + + const handleOverlayClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget) { + onClose(); + } + }; + + if (!isOpen) { + return null; + } + + return ( +
+
+
+

+

+ +
+ +
+ {logs.length === 0 ? ( +
+
+ ) : ( + logs.map((entry, index) => ( +
+ {formatLogEntry(entry)} +
+ )) + )} +
+ +
+ + + +
+
+
+ ); +} + +export { DebugLogsModal }; +export type { DebugLogsModalProps }; diff --git a/src/renderer/components/ui/DebugLogsModal/index.ts b/src/renderer/components/ui/DebugLogsModal/index.ts new file mode 100644 index 0000000..ab1aa3e --- /dev/null +++ b/src/renderer/components/ui/DebugLogsModal/index.ts @@ -0,0 +1,2 @@ +export { DebugLogsModal } from './DebugLogsModal'; +export type { DebugLogsModalProps } from './DebugLogsModal'; diff --git a/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx index 75238ac..14409ef 100644 --- a/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx +++ b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx @@ -1,5 +1,6 @@ import React, { type ReactNode } from 'react'; import { AlertTriangle, RefreshCw } from 'lucide-react'; +import { logger } from '../../../services/logger'; import './ErrorBoundary.css'; export interface ErrorBoundaryProps { @@ -23,10 +24,11 @@ class ErrorBoundary extends React.Component { diff --git a/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx b/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx index 73d0083..1d19837 100644 --- a/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx +++ b/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx @@ -62,20 +62,28 @@ describe('SystemWarning', () => { }); it('calls onRefresh when check button is clicked', async () => { + let resolveRefresh: (value: boolean) => void; + const refreshPromise = new Promise((resolve) => { + resolveRefresh = resolve; + }); + mockOnRefresh.mockReturnValue(refreshPromise); + render(); const refreshButton = screen.getByRole('button', { name: /I have installed FFmpeg/i }); - await act(async () => { - fireEvent.click(refreshButton); - }); + fireEvent.click(refreshButton); - expect(mockOnRefresh).toHaveBeenCalled(); expect(screen.getByText(/Verifying Installation.../i)).toBeInTheDocument(); + expect(refreshButton).toBeDisabled(); + expect(mockOnRefresh).toHaveBeenCalled(); + + resolveRefresh!(true); await waitFor(() => { expect(refreshButton).not.toBeDisabled(); }); + expect(screen.queryByText(/Verifying Installation.../i)).not.toBeInTheDocument(); }); it('copies command to clipboard', async () => { diff --git a/src/renderer/components/ui/index.ts b/src/renderer/components/ui/index.ts index 35504fc..f450df6 100644 --- a/src/renderer/components/ui/index.ts +++ b/src/renderer/components/ui/index.ts @@ -2,3 +2,5 @@ export { ErrorBoundary } from './ErrorBoundary'; export type { ErrorBoundaryProps } from './ErrorBoundary'; export { ProgressBar } from './ProgressBar'; export { SystemWarning } from './SystemWarning'; +export { DebugLogsModal } from './DebugLogsModal'; +export type { DebugLogsModalProps } from './DebugLogsModal'; diff --git a/src/renderer/features/transcription/hooks/useTranscription.ts b/src/renderer/features/transcription/hooks/useTranscription.ts index 99a901a..ef8a4b4 100644 --- a/src/renderer/features/transcription/hooks/useTranscription.ts +++ b/src/renderer/features/transcription/hooks/useTranscription.ts @@ -15,6 +15,7 @@ import { onTranscriptionProgress, saveFile, } from '../../../services/electronAPI'; +import { logger } from '../../../services/logger'; interface UseTranscriptionOptions { onHistoryAdd?: (item: HistoryItem) => void; @@ -105,6 +106,13 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran setError(null); setProgress({ percent: 0, status: '' }); setTranscriptionStartTime(null); + + logger.info('File selected', { + name: file.name, + path: file.path, + + size: file.size, + }); }, [clearProgressMessageTimeout] ); @@ -128,6 +136,12 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran setProgress({ percent: 0, status: 'Starting transcription...' }); setTranscriptionStartTime(Date.now()); + logger.info('Starting transcription', { + file: selectedFile.path, + model: settings.model, + language: settings.language, + }); + const startTime = Date.now(); try { @@ -142,18 +156,34 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran throw new Error('No response from transcription service'); } + if (result.error) { + throw new Error(result.error); + } + + if (!result.success) { + throw new Error('Transcription failed with unknown error'); + } + if (result.cancelled) { setProgress({ percent: 0, status: 'Cancelled' }); + logger.info('Transcription cancelled by service'); return; } if (!result.text) { - throw new Error('Transcription produced no output'); + throw new Error( + 'Transcription produced no output. The file may be silent or contain no audio stream.' + ); } setTranscription(result.text); setProgress({ percent: 100, status: 'Complete!' }); + logger.info('Transcription complete', { + durationMs: Date.now() - startTime, + length: result.text.length, + }); + const historyItem: HistoryItem = { id: Date.now(), fileName: selectedFile.name, @@ -168,8 +198,16 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran onHistoryAdd?.(historyItem); scheduleProgressReset(APP_CONFIG.TRANSCRIPTION_COMPLETE_MESSAGE_DURATION); } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - setError(errorMessage); + const fullError = err instanceof Error ? err.message : 'Unknown error occurred'; + + const uiError = + fullError.includes('FFmpeg conversion failed') || + fullError.includes('whisper process exited') + ? 'Transcription produced no output. The file may be invalid or missing audio. (See Debug Logs for details)' + : fullError; + + setError(uiError); + logger.error('Transcription failed', { error: err, message: fullError }); setProgress({ percent: 0, status: '' }); setTranscriptionStartTime(null); clearProgressMessageTimeout(); @@ -183,6 +221,7 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran clearProgressMessageTimeout(); setIsTranscribing(false); setProgress({ percent: 0, status: 'Cancelled' }); + logger.warn('Transcription cancelled by user'); setTranscriptionStartTime(null); scheduleProgressReset(APP_CONFIG.TRANSCRIPTION_COMPLETE_MESSAGE_DURATION); }, [clearProgressMessageTimeout, scheduleProgressReset]); @@ -232,9 +271,11 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran if (result?.success && result.filePath) { setProgress({ percent: 100, status: `Saved to ${result.filePath}` }); + logger.info('File saved', { path: result.filePath, format }); scheduleProgressReset(APP_CONFIG.SAVE_SUCCESS_MESSAGE_DURATION); } else if (result?.error) { setError(`Failed to save: ${result.error}`); + logger.error('Failed to save file', { error: result.error, format }); } }, [transcription, selectedFile, scheduleProgressReset] @@ -246,6 +287,9 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran const success = await copyToClipboard(transcription); if (!success) { setError('Failed to copy to clipboard'); + logger.error('Failed to copy transcription to clipboard'); + } else { + logger.info('Copied transcription to clipboard'); } return success; }, diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index fa51b48..6fe7f3f 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -9,3 +9,6 @@ export type { UseCopyToClipboardReturn } from './useCopyToClipboard'; export { useFFmpegStatus } from './useFFmpegStatus'; export type { UseFFmpegStatusReturn } from './useFFmpegStatus'; + +export { useDebugLogs } from './useDebugLogs'; +export type { UseDebugLogsReturn } from './useDebugLogs'; diff --git a/src/renderer/hooks/useDebugLogs.ts b/src/renderer/hooks/useDebugLogs.ts new file mode 100644 index 0000000..584b4ba --- /dev/null +++ b/src/renderer/hooks/useDebugLogs.ts @@ -0,0 +1,122 @@ +import { useState, useCallback, useEffect } from 'react'; +import { logger, type LogEntry } from '../services/logger'; +import { useCopyToClipboard } from './useCopyToClipboard'; +import { getAppInfo } from '../services/electronAPI'; + +interface UseDebugLogsReturn { + logs: LogEntry[]; + isOpen: boolean; + openModal: () => void; + closeModal: () => void; + copyLogs: () => Promise; + copyLogsWithSystemInfo: () => Promise; + clearLogs: () => void; + refreshLogs: () => void; +} + +function formatLogEntryForExport(entry: LogEntry): string { + const timestamp = entry.timestamp.toISOString(); + const level = entry.level.toUpperCase().padEnd(5); + const data = entry.data !== undefined ? ` | ${JSON.stringify(entry.data)}` : ''; + return `[${timestamp}] [${level}] ${entry.message}${data}`; +} + +function formatLogsForExport(logs: LogEntry[]): string { + if (logs.length === 0) { + return 'No logs captured.'; + } + return logs.map(formatLogEntryForExport).join('\n'); +} + +function getSystemInfo(osVersion?: string): string { + const info: string[] = [ + '## System Information', + '', + `- **Platform**: ${navigator.platform}`, + `- **OS Version**: ${osVersion || 'Unknown'}`, + `- **User Agent**: ${navigator.userAgent}`, + `- **User Agent**: ${navigator.userAgent}`, + `- **Language**: ${navigator.language}`, + `- **Online**: ${navigator.onLine}`, + `- **Timestamp**: ${new Date().toISOString()}`, + ]; + + if ('memory' in performance) { + const memory = ( + performance as { + memory: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number }; + } + ).memory; + info.push(`- **JS Heap Used**: ${Math.round(memory.usedJSHeapSize / 1024 / 1024)} MB`); + info.push(`- **JS Heap Total**: ${Math.round(memory.totalJSHeapSize / 1024 / 1024)} MB`); + } + + return info.join('\n'); +} + +function useDebugLogs(): UseDebugLogsReturn { + const [logs, setLogs] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const { copyToClipboard } = useCopyToClipboard(); + + const refreshLogs = useCallback(() => { + setLogs(logger.getLogs()); + }, []); + + const openModal = useCallback(() => { + refreshLogs(); + setIsOpen(true); + }, [refreshLogs]); + + const closeModal = useCallback(() => { + setIsOpen(false); + }, []); + + const clearLogs = useCallback(() => { + logger.clearLogs(); + setLogs([]); + }, []); + + const copyLogs = useCallback(async (): Promise => { + const formattedLogs = formatLogsForExport(logs); + const content = ['## Debug Logs', '', '```', formattedLogs, '```'].join('\n'); + + return copyToClipboard(content); + }, [logs, copyToClipboard]); + + const copyLogsWithSystemInfo = useCallback(async (): Promise => { + let osVersion = 'Unknown'; + try { + const appInfo = await getAppInfo(); + if (appInfo.osVersion) osVersion = appInfo.osVersion; + } catch (error) { + console.error('Failed to get app info for system logs:', error); + } + + const formattedLogs = formatLogsForExport(logs); + const systemInfo = getSystemInfo(osVersion); + const content = [systemInfo, '', '## Debug Logs', '', '```', formattedLogs, '```'].join('\n'); + + return copyToClipboard(content); + }, [logs, copyToClipboard]); + + useEffect(() => { + if (isOpen) { + refreshLogs(); + } + }, [isOpen, refreshLogs]); + + return { + logs, + isOpen, + openModal, + closeModal, + copyLogs, + copyLogsWithSystemInfo, + clearLogs, + refreshLogs, + }; +} + +export { useDebugLogs }; +export type { UseDebugLogsReturn }; diff --git a/src/renderer/services/logger.ts b/src/renderer/services/logger.ts index 7a3d82d..2cffc0f 100644 --- a/src/renderer/services/logger.ts +++ b/src/renderer/services/logger.ts @@ -8,6 +8,8 @@ interface LogEntry { } const isDev = process.env.NODE_ENV === 'development'; +const MAX_LOG_ENTRIES = 500; +const logBuffer: LogEntry[] = []; function formatMessage(level: LogLevel, message: string, data?: unknown): string { const timestamp = new Date().toISOString(); @@ -15,7 +17,24 @@ function formatMessage(level: LogLevel, message: string, data?: unknown): string return data !== undefined ? `${prefix} ${message}` : `${prefix} ${message}`; } +function storeLogEntry(level: LogLevel, message: string, data?: unknown): void { + const entry: LogEntry = { + level, + message, + data, + timestamp: new Date(), + }; + + logBuffer.push(entry); + + if (logBuffer.length > MAX_LOG_ENTRIES) { + logBuffer.shift(); + } +} + function log(level: LogLevel, message: string, data?: unknown): void { + storeLogEntry(level, message, data); + if (!isDev) return; const formattedMessage = formatMessage(level, message, data); @@ -52,12 +71,27 @@ function log(level: LogLevel, message: string, data?: unknown): void { } } +function getLogs(): LogEntry[] { + return [...logBuffer]; +} + +function clearLogs(): void { + logBuffer.length = 0; +} + +function getLogCount(): number { + return logBuffer.length; +} + export const logger = { debug: (message: string, data?: unknown): void => log('debug', message, data), info: (message: string, data?: unknown): void => log('info', message, data), warn: (message: string, data?: unknown): void => log('warn', message, data), error: (message: string, data?: unknown): void => log('error', message, data), isEnabled: (): boolean => isDev, + getLogs, + clearLogs, + getLogCount, }; export type { LogEntry, LogLevel }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 1890346..fae40c0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -114,6 +114,7 @@ export interface AppInfo { isDev: boolean; version: string; platform: NodeJS.Platform; + osVersion?: string; } export interface MemoryUsage { From 86b226639f62d32adfe9ccf39db361bfc0539306 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 18 Dec 2025 01:47:47 -0300 Subject: [PATCH 18/22] refactor: update SettingsPanel to use new components and improve structure --- .../components/GpuStatus/GpuStatus.css | 26 ++ .../components/GpuStatus/GpuStatus.tsx | 28 +++ .../settings/components/GpuStatus/index.ts | 2 + .../LanguageSelector/LanguageSelector.tsx | 41 ++++ .../components/LanguageSelector/index.ts | 2 + .../components/ModelDetails/ModelDetails.css | 132 ++++++++++ .../components/ModelDetails/ModelDetails.tsx | 88 +++++++ .../settings/components/ModelDetails/index.ts | 2 + .../ModelSelector/ModelSelector.css | 42 ++++ .../ModelSelector/ModelSelector.tsx | 48 ++++ .../components/ModelSelector/index.ts | 2 + .../SettingsPanel/SettingsPanel.css | 203 --------------- .../SettingsPanel/SettingsPanel.tsx | 169 +++---------- .../features/settings/components/index.ts | 5 + .../OutputDisplay/OutputDisplay.css | 231 ------------------ .../OutputDisplay/OutputDisplay.tsx | 208 +++------------- .../TranscriptionContent.css | 56 +++++ .../TranscriptionContent.tsx | 55 +++++ .../components/TranscriptionContent/index.ts | 2 + .../TranscriptionSearch.css | 73 ++++++ .../TranscriptionSearch.tsx | 84 +++++++ .../components/TranscriptionSearch/index.ts | 2 + .../TranscriptionToolbar.css | 99 ++++++++ .../TranscriptionToolbar.tsx | 115 +++++++++ .../components/TranscriptionToolbar/index.ts | 2 + .../transcription/components/index.ts | 4 + 26 files changed, 982 insertions(+), 739 deletions(-) create mode 100644 src/renderer/features/settings/components/GpuStatus/GpuStatus.css create mode 100644 src/renderer/features/settings/components/GpuStatus/GpuStatus.tsx create mode 100644 src/renderer/features/settings/components/GpuStatus/index.ts create mode 100644 src/renderer/features/settings/components/LanguageSelector/LanguageSelector.tsx create mode 100644 src/renderer/features/settings/components/LanguageSelector/index.ts create mode 100644 src/renderer/features/settings/components/ModelDetails/ModelDetails.css create mode 100644 src/renderer/features/settings/components/ModelDetails/ModelDetails.tsx create mode 100644 src/renderer/features/settings/components/ModelDetails/index.ts create mode 100644 src/renderer/features/settings/components/ModelSelector/ModelSelector.css create mode 100644 src/renderer/features/settings/components/ModelSelector/ModelSelector.tsx create mode 100644 src/renderer/features/settings/components/ModelSelector/index.ts create mode 100644 src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.css create mode 100644 src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.tsx create mode 100644 src/renderer/features/transcription/components/TranscriptionContent/index.ts create mode 100644 src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.css create mode 100644 src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.tsx create mode 100644 src/renderer/features/transcription/components/TranscriptionSearch/index.ts create mode 100644 src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css create mode 100644 src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx create mode 100644 src/renderer/features/transcription/components/TranscriptionToolbar/index.ts diff --git a/src/renderer/features/settings/components/GpuStatus/GpuStatus.css b/src/renderer/features/settings/components/GpuStatus/GpuStatus.css new file mode 100644 index 0000000..abeb51a --- /dev/null +++ b/src/renderer/features/settings/components/GpuStatus/GpuStatus.css @@ -0,0 +1,26 @@ +.gpu-status { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: var(--radius-sm); + margin-bottom: 14px; + font-size: 0.85rem; +} + +.gpu-available { + background: var(--success-light); + border: 1px solid var(--success); + color: var(--success); +} + +.gpu-unavailable { + background: var(--bg-tertiary); + border: 1px solid var(--border); + color: var(--text-secondary); +} + +.gpu-icon { + display: flex; + align-items: center; +} diff --git a/src/renderer/features/settings/components/GpuStatus/GpuStatus.tsx b/src/renderer/features/settings/components/GpuStatus/GpuStatus.tsx new file mode 100644 index 0000000..9569296 --- /dev/null +++ b/src/renderer/features/settings/components/GpuStatus/GpuStatus.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Zap, Cpu } from 'lucide-react'; +import './GpuStatus.css'; +import type { GpuInfo } from '../../../../types'; + +export interface GpuStatusProps { + gpuInfo: GpuInfo | null; +} + +function GpuStatus({ gpuInfo }: GpuStatusProps): React.JSX.Element | null { + if (!gpuInfo) return null; + + return ( +
+ + {gpuInfo.name} +
+ ); +} + +export { GpuStatus }; diff --git a/src/renderer/features/settings/components/GpuStatus/index.ts b/src/renderer/features/settings/components/GpuStatus/index.ts new file mode 100644 index 0000000..d5f2fe1 --- /dev/null +++ b/src/renderer/features/settings/components/GpuStatus/index.ts @@ -0,0 +1,2 @@ +export { GpuStatus } from './GpuStatus'; +export type { GpuStatusProps } from './GpuStatus'; diff --git a/src/renderer/features/settings/components/LanguageSelector/LanguageSelector.tsx b/src/renderer/features/settings/components/LanguageSelector/LanguageSelector.tsx new file mode 100644 index 0000000..52b2ec1 --- /dev/null +++ b/src/renderer/features/settings/components/LanguageSelector/LanguageSelector.tsx @@ -0,0 +1,41 @@ +import React, { type ChangeEvent } from 'react'; +import '../ModelSelector/ModelSelector.css'; +import type { LanguageCode } from '../../../../types'; +import { LANGUAGES } from '../../../../config'; + +export interface LanguageSelectorProps { + selectedLanguage: LanguageCode; + disabled: boolean; + onChange: (language: LanguageCode) => void; +} + +function LanguageSelector({ + selectedLanguage, + disabled, + onChange, +}: LanguageSelectorProps): React.JSX.Element { + const handleChange = (e: ChangeEvent): void => { + onChange(e.target.value as LanguageCode); + }; + + return ( +
+ + +
+ ); +} + +export { LanguageSelector }; diff --git a/src/renderer/features/settings/components/LanguageSelector/index.ts b/src/renderer/features/settings/components/LanguageSelector/index.ts new file mode 100644 index 0000000..3c10aed --- /dev/null +++ b/src/renderer/features/settings/components/LanguageSelector/index.ts @@ -0,0 +1,2 @@ +export { LanguageSelector } from './LanguageSelector'; +export type { LanguageSelectorProps } from './LanguageSelector'; diff --git a/src/renderer/features/settings/components/ModelDetails/ModelDetails.css b/src/renderer/features/settings/components/ModelDetails/ModelDetails.css new file mode 100644 index 0000000..ef019fd --- /dev/null +++ b/src/renderer/features/settings/components/ModelDetails/ModelDetails.css @@ -0,0 +1,132 @@ +.model-details { + margin-top: 10px; + padding: 12px; + background: var(--surface); + border-radius: var(--radius-sm); + border: 1px solid var(--border); + transition: all var(--transition-base); +} + +.model-info-row { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.model-stat { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; +} + +.stat-label { + color: var(--text-muted); +} + +.stat-value { + color: var(--text-primary); + font-weight: 500; +} + +.stat-value.quality { + color: var(--warning); + letter-spacing: 2px; +} + +.model-download { + margin-top: 10px; +} + +.btn-download { + width: 100%; + padding: 8px 12px; + font-size: 0.85rem; + background: var(--accent-light); + border: 1px solid var(--accent-border); + color: var(--accent); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn-download:hover { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.downloading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.spinner { + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.model-ready { + font-size: 0.85rem; + color: var(--success); + display: flex; + align-items: center; + gap: 4px; +} + +.download-progress { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.progress-text { + font-size: 0.75rem; + color: var(--text-muted); +} + +.model-ready-container { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} + +.btn-delete-model { + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); + font-size: 1rem; + opacity: 0.6; + color: var(--text-muted); +} + +.btn-delete-model:hover { + background: var(--danger-light); + opacity: 1; + color: var(--danger); +} + +.btn-delete-model:disabled { + opacity: 0.3; + cursor: not-allowed; +} diff --git a/src/renderer/features/settings/components/ModelDetails/ModelDetails.tsx b/src/renderer/features/settings/components/ModelDetails/ModelDetails.tsx new file mode 100644 index 0000000..65da7f2 --- /dev/null +++ b/src/renderer/features/settings/components/ModelDetails/ModelDetails.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Download, Check, Trash2 } from 'lucide-react'; +import './ModelDetails.css'; +import type { ModelInfo, ModelDownloadProgress } from '../../../../types'; +import { QUALITY_STARS } from '../../../../config'; + +export interface ModelDetailsProps { + model: ModelInfo | undefined; + downloading: string | null; + downloadProgress: ModelDownloadProgress | null; + disabled: boolean; + onDownload: (modelName: string) => void; + onDelete: (modelName: string) => void; +} + +function ModelDetails({ + model, + downloading, + downloadProgress, + disabled, + onDownload, + onDelete, +}: ModelDetailsProps): React.JSX.Element | null { + if (!model) return null; + + const trimmedRemainingTime = downloadProgress?.remainingTime?.trim() ?? ''; + + return ( +
+
+ + Speed: + {model.speed} + + + Quality: + {QUALITY_STARS[model.quality - 1]} + +
+ + {!model.downloaded && ( +
+ {downloading === model.name ? ( +
+ + Downloading... + + {downloadProgress && downloadProgress.percent !== undefined && ( + + {downloadProgress.percent}% + {trimmedRemainingTime && ` (${trimmedRemainingTime} left)`} + + )} +
+ ) : ( + + )} +
+ )} + + {model.downloaded && ( +
+
+
+ +
+ )} +
+ ); +} + +export { ModelDetails }; diff --git a/src/renderer/features/settings/components/ModelDetails/index.ts b/src/renderer/features/settings/components/ModelDetails/index.ts new file mode 100644 index 0000000..698c3e2 --- /dev/null +++ b/src/renderer/features/settings/components/ModelDetails/index.ts @@ -0,0 +1,2 @@ +export { ModelDetails } from './ModelDetails'; +export type { ModelDetailsProps } from './ModelDetails'; diff --git a/src/renderer/features/settings/components/ModelSelector/ModelSelector.css b/src/renderer/features/settings/components/ModelSelector/ModelSelector.css new file mode 100644 index 0000000..4c5ec3e --- /dev/null +++ b/src/renderer/features/settings/components/ModelSelector/ModelSelector.css @@ -0,0 +1,42 @@ +.setting-group { + margin-bottom: 14px; +} + +.setting-group:last-child { + margin-bottom: 0; +} + +.setting-group label { + display: block; + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 6px; + color: var(--text-primary); +} + +.setting-group select { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 0.9rem; + cursor: pointer; + transition: all var(--transition-fast); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238a8f98' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 32px; +} + +.setting-group select:hover { + border-color: var(--accent-border); +} + +.setting-group select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} diff --git a/src/renderer/features/settings/components/ModelSelector/ModelSelector.tsx b/src/renderer/features/settings/components/ModelSelector/ModelSelector.tsx new file mode 100644 index 0000000..529dc04 --- /dev/null +++ b/src/renderer/features/settings/components/ModelSelector/ModelSelector.tsx @@ -0,0 +1,48 @@ +import React, { type ChangeEvent } from 'react'; +import './ModelSelector.css'; +import type { ModelInfo, WhisperModelName } from '../../../../types'; + +export interface ModelSelectorProps { + models: ModelInfo[]; + selectedModel: WhisperModelName; + disabled: boolean; + loading: boolean; + onChange: (model: WhisperModelName) => void; + ariaDescribedBy?: string; +} + +function ModelSelector({ + models, + selectedModel, + disabled, + loading, + onChange, + ariaDescribedBy, +}: ModelSelectorProps): React.JSX.Element { + const handleChange = (e: ChangeEvent): void => { + onChange(e.target.value as WhisperModelName); + }; + + return ( +
+ + +
+ ); +} + +export { ModelSelector }; diff --git a/src/renderer/features/settings/components/ModelSelector/index.ts b/src/renderer/features/settings/components/ModelSelector/index.ts new file mode 100644 index 0000000..7d8d66f --- /dev/null +++ b/src/renderer/features/settings/components/ModelSelector/index.ts @@ -0,0 +1,2 @@ +export { ModelSelector } from './ModelSelector'; +export type { ModelSelectorProps } from './ModelSelector'; diff --git a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.css b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.css index fe767cd..93fa206 100644 --- a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.css +++ b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.css @@ -19,206 +19,3 @@ text-transform: uppercase; letter-spacing: 0.8px; } - -.gpu-status { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - border-radius: var(--radius-sm); - margin-bottom: 14px; - font-size: 0.85rem; -} - -.gpu-available { - background: var(--success-light); - border: 1px solid var(--success); - color: var(--success); -} - -.gpu-unavailable { - background: var(--bg-tertiary); - border: 1px solid var(--border); - color: var(--text-secondary); -} - -.gpu-icon { - display: flex; - align-items: center; -} - -.setting-group { - margin-bottom: 14px; -} - -.setting-group:last-child { - margin-bottom: 0; -} - -.setting-group label { - display: block; - font-size: 0.85rem; - font-weight: 500; - margin-bottom: 6px; - color: var(--text-primary); -} - -.setting-group select { - width: 100%; - padding: 10px 12px; - border-radius: var(--radius-sm); - border: 1px solid var(--border); - background: var(--bg-secondary); - color: var(--text-primary); - font-size: 0.9rem; - cursor: pointer; - transition: all var(--transition-fast); - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238a8f98' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - padding-right: 32px; -} - -.setting-group select:hover { - border-color: var(--accent-border); -} - -.setting-group select:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-light); -} - -.model-details { - margin-top: 10px; - padding: 12px; - background: var(--surface); - border-radius: var(--radius-sm); - border: 1px solid var(--border); - transition: all var(--transition-base); -} - -.model-info-row { - display: flex; - justify-content: space-between; - margin-bottom: 10px; -} - -.model-stat { - display: flex; - align-items: center; - gap: 6px; - font-size: 0.85rem; -} - -.stat-label { - color: var(--text-muted); -} - -.stat-value { - color: var(--text-primary); - font-weight: 500; -} - -.stat-value.quality { - color: var(--warning); - letter-spacing: 2px; -} - -.model-download { - margin-top: 10px; -} - -.btn-download { - width: 100%; - padding: 8px 12px; - font-size: 0.85rem; - background: var(--accent-light); - border: 1px solid var(--accent-border); - color: var(--accent); - border-radius: var(--radius-sm); - cursor: pointer; - transition: all var(--transition-fast); -} - -.btn-download:hover { - background: var(--accent); - color: white; - border-color: var(--accent); -} - -.downloading { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 8px; - font-size: 0.85rem; - color: var(--text-secondary); -} - -.spinner { - width: 14px; - height: 14px; - border: 2px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.model-ready { - font-size: 0.85rem; - color: var(--success); - display: flex; - align-items: center; - gap: 4px; -} - -.download-progress { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; -} - -.progress-text { - font-size: 0.75rem; - color: var(--text-muted); -} - -.model-ready-container { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 8px; -} - -.btn-delete-model { - background: transparent; - border: none; - cursor: pointer; - padding: 4px; - border-radius: var(--radius-sm); - transition: all var(--transition-fast); - font-size: 1rem; - opacity: 0.6; - color: var(--text-muted); -} - -.btn-delete-model:hover { - background: var(--danger-light); - opacity: 1; - color: var(--danger); -} - -.btn-delete-model:disabled { - opacity: 0.3; - cursor: not-allowed; -} diff --git a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx index da95aa9..05c0e1f 100644 --- a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx +++ b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx @@ -1,5 +1,4 @@ -import React, { useState, useEffect, type ChangeEvent } from 'react'; -import { Download, Check, Trash2, Zap, Cpu } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; import './SettingsPanel.css'; import type { @@ -10,7 +9,6 @@ import type { WhisperModelName, LanguageCode, } from '../../../../types'; -import { LANGUAGES, QUALITY_STARS } from '../../../../config'; import { DEFAULT_MODELS } from '../../services/modelService'; import { listModels, @@ -21,6 +19,11 @@ import { logger, } from '../../../../services'; +import { GpuStatus } from '../GpuStatus'; +import { ModelSelector } from '../ModelSelector'; +import { ModelDetails } from '../ModelDetails'; +import { LanguageSelector } from '../LanguageSelector'; + export interface SettingsPanelProps { settings: TranscriptionSettings; onChange: (settings: TranscriptionSettings) => void; @@ -96,13 +99,13 @@ function SettingsPanel({ } }, [models, onChange, settings]); - const handleChange = (key: keyof TranscriptionSettings, value: string): void => { - if (key === 'model') { - onChange({ ...settings, model: value as WhisperModelName }); - localStorage.setItem('whisperdesk_lastModel', value); - } else if (key === 'language') { - onChange({ ...settings, language: value as LanguageCode }); - } + const handleModelChange = (model: WhisperModelName): void => { + onChange({ ...settings, model }); + localStorage.setItem('whisperdesk_lastModel', model); + }; + + const handleLanguageChange = (language: LanguageCode): void => { + onChange({ ...settings, language }); }; const handleDownloadModel = async (modelName: string): Promise => { @@ -139,131 +142,37 @@ function SettingsPanel({ } }; - const handleModelChange = (e: ChangeEvent): void => { - handleChange('model', e.target.value); - }; - - const handleLanguageChange = (e: ChangeEvent): void => { - handleChange('language', e.target.value); - }; - - const selectedModel = models.find((m) => m.name === settings.model); - const trimmedRemainingTime = downloadProgress?.remainingTime?.trim() ?? ''; + const selectedModelInfo = models.find((m) => m.name === settings.model); return (

Settings

- {gpuInfo && ( -
- - {gpuInfo.name} -
- )} - -
- - - - {selectedModel && ( -
-
- - Speed: - {selectedModel.speed} - - - Quality: - - {QUALITY_STARS[selectedModel.quality - 1]} - - -
- - {!selectedModel.downloaded && ( -
- {downloading === selectedModel.name ? ( -
- - Downloading... - - {downloadProgress && downloadProgress.percent !== undefined && ( - - {downloadProgress.percent}% - {trimmedRemainingTime && ` (${trimmedRemainingTime} left)`} - - )} -
- ) : ( - - )} -
- )} - - {selectedModel.downloaded && ( -
-
-
- -
- )} -
- )} -
- -
- - -
+ + + + + + +
); } diff --git a/src/renderer/features/settings/components/index.ts b/src/renderer/features/settings/components/index.ts index ccbadc7..7037834 100644 --- a/src/renderer/features/settings/components/index.ts +++ b/src/renderer/features/settings/components/index.ts @@ -1,2 +1,7 @@ export { SettingsPanel } from './SettingsPanel'; export type { SettingsPanelProps } from './SettingsPanel'; + +export * from './GpuStatus'; +export * from './ModelSelector'; +export * from './ModelDetails'; +export * from './LanguageSelector'; diff --git a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.css b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.css index 1f85999..241b7db 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.css +++ b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.css @@ -10,234 +10,3 @@ min-height: 0; transition: all var(--transition-base); } - -.output-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 14px 18px; - border-bottom: 1px solid var(--border); - gap: 12px; - background: var(--surface); -} - -.output-header h3 { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.8px; -} - -.output-meta { - flex: 1; -} - -.word-count { - font-size: 0.8rem; - color: var(--text-muted); -} - -.output-actions { - display: flex; - gap: 8px; -} - -.output-actions .btn-icon { - padding: 6px 12px; - font-size: 0.8rem; - background: var(--surface); - border: 1px solid var(--border); - color: var(--text-primary); - border-radius: var(--radius-sm); - transition: all var(--transition-fast); -} - -.output-actions .btn-icon:hover { - background: var(--surface-hover); - border-color: var(--border-hover); -} - -.output-actions .btn-icon.success { - background: var(--success-light); - border-color: var(--success); - color: var(--success); -} - -.save-dropdown { - position: relative; -} - -.save-menu { - position: absolute; - top: 100%; - right: 0; - margin-top: 4px; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - box-shadow: var(--shadow-lg); - min-width: 160px; - z-index: 100; - overflow: hidden; -} - -.save-menu-item { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - padding: 10px 14px; - font-size: 0.85rem; - background: transparent; - border: none; - color: var(--text-primary); - cursor: pointer; - transition: background var(--transition-fast); -} - -.save-menu-item:hover { - background: var(--surface-hover); -} - -.format-ext { - color: var(--text-muted); - font-size: 0.75rem; -} - -.output-actions .btn-icon.active { - background: var(--accent-light); - border-color: var(--accent); - color: var(--accent); -} - -.search-bar { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 18px; - background: var(--surface); - border-bottom: 1px solid var(--border); -} - -.search-input { - flex: 1; - padding: 8px 12px; - font-size: 0.9rem; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-primary); - outline: none; - transition: all var(--transition-fast); -} - -.search-input:focus { - border-color: var(--accent); -} - -.search-input::placeholder { - color: var(--text-muted); -} - -.search-nav { - display: flex; - align-items: center; - gap: 4px; -} - -.search-count { - font-size: 0.8rem; - color: var(--text-secondary); - padding: 0 8px; - white-space: nowrap; -} - -.search-nav-btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - font-size: 0.85rem; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-primary); - cursor: pointer; - transition: all var(--transition-fast); -} - -.search-nav-btn:hover:not(:disabled) { - background: var(--surface-hover); - border-color: var(--border-hover); -} - -.search-nav-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.search-nav-btn.close:hover { - background: var(--danger-light); - border-color: var(--danger); - color: var(--danger); -} - -.search-highlight { - background: rgba(255, 213, 0, 0.4); - border-radius: 2px; - padding: 0 1px; -} - -.search-highlight.current { - background: rgba(255, 170, 0, 0.7); - outline: 2px solid var(--accent); - outline-offset: 1px; -} - -.output-content { - flex: 1; - padding: 20px; - overflow-y: auto; - background: var(--bg-secondary); - min-height: 0; -} - -.transcription-text { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 0.95rem; - line-height: 1.8; - white-space: pre-wrap; - word-wrap: break-word; - color: var(--text-primary); - margin: 0; - -webkit-user-select: text; - user-select: text; - cursor: text; -} - -.output-placeholder { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - gap: 12px; - text-align: center; - color: var(--text-secondary); -} - -.placeholder-icon { - opacity: 0.5; -} - -.output-placeholder span { - font-size: 1rem; -} - -.placeholder-hint { - font-size: 0.85rem; - color: var(--text-muted); -} diff --git a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx index 825e1ee..02dd9b0 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx +++ b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx @@ -1,8 +1,10 @@ -import React, { useState, useEffect, useRef, useMemo, type ChangeEvent } from 'react'; -import { Search, Check, Copy, Save, ChevronUp, ChevronDown, X, FileText } from 'lucide-react'; -import type { OutputFormat } from '../../../../types'; -import { OUTPUT_FORMATS } from '../../../../config'; +import React, { useState, useEffect, useMemo } from 'react'; import './OutputDisplay.css'; +import type { OutputFormat } from '../../../../types'; + +import { TranscriptionToolbar } from '../TranscriptionToolbar'; +import { TranscriptionSearch } from '../TranscriptionSearch'; +import { TranscriptionContent } from '../TranscriptionContent'; export interface OutputDisplayProps { text: string; @@ -22,16 +24,11 @@ function OutputDisplay({ onCopy, copySuccess, }: OutputDisplayProps): React.JSX.Element { - const [showSaveMenu, setShowSaveMenu] = useState(false); const [showSearch, setShowSearch] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); - const saveMenuRef = useRef(null); - const searchInputRef = useRef(null); - const contentRef = useRef(null); - - const hasText = text && text.length > 0; + const hasText = text.length > 0; const wordCount = hasText ? text.trim().split(/\s+/).length : 0; const charCount = hasText ? text.length : 0; @@ -54,12 +51,6 @@ function OutputDisplay({ setCurrentMatchIndex(0); }, [searchQuery]); - useEffect(() => { - if (showSearch && searchInputRef.current) { - searchInputRef.current.focus(); - } - }, [showSearch]); - useEffect(() => { const handleKeyDown = (e: globalThis.KeyboardEvent): void => { if ((e.metaKey || e.ctrlKey) && e.key === 'f' && hasText) { @@ -84,28 +75,6 @@ function OutputDisplay({ return () => document.removeEventListener('keydown', handleKeyDown); }, [hasText, showSearch, matches.length]); - useEffect(() => { - const handleClickOutside = (e: MouseEvent): void => { - if (saveMenuRef.current && !saveMenuRef.current.contains(e.target as Node)) { - setShowSaveMenu(false); - } - }; - - if (showSaveMenu) { - document.addEventListener('mousedown', handleClickOutside); - } - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [showSaveMenu]); - - const handleSaveFormat = (format: OutputFormat): void => { - setShowSaveMenu(false); - onSave(format); - }; - - const handleSearchChange = (e: ChangeEvent): void => { - setSearchQuery(e.target.value); - }; - const handleToggleSearch = (): void => { setShowSearch(!showSearch); if (showSearch) { @@ -155,149 +124,38 @@ function OutputDisplay({ return parts; }, [text, searchQuery, matches, currentMatchIndex]); - useEffect(() => { - if (matches.length > 0 && contentRef.current) { - const currentMark = contentRef.current.querySelector('.search-highlight.current'); - if (currentMark) { - currentMark.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - } - }, [currentMatchIndex, matches.length]); - return (
-
-

Transcription

-
- {hasText && ( - - {wordCount} words ยท {charCount} chars - - )} -
- {hasText && ( -
- - -
- - {showSaveMenu && ( -
- {OUTPUT_FORMATS.map((format) => ( - - ))} -
- )} -
-
- )} -
+ {showSearch && hasText && ( -
- -
- {searchQuery && ( - - {matches.length > 0 - ? `${currentMatchIndex + 1} of ${matches.length}` - : 'No matches'} - - )} - - - -
-
+ )} -
- {hasText ? ( -
-            {highlightedText || text}
-          
- ) : ( -
- - - Transcription will appear here - Select a file and click Transcribe to start -
- )} -
+
); } diff --git a/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.css b/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.css new file mode 100644 index 0000000..b7c7b6c --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.css @@ -0,0 +1,56 @@ +.output-content { + flex: 1; + padding: 20px; + overflow-y: auto; + background: var(--bg-secondary); + min-height: 0; +} + +.transcription-text { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 0.95rem; + line-height: 1.8; + white-space: pre-wrap; + word-wrap: break-word; + color: var(--text-primary); + margin: 0; + -webkit-user-select: text; + user-select: text; + cursor: text; +} + +.output-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 12px; + text-align: center; + color: var(--text-secondary); +} + +.placeholder-icon { + opacity: 0.5; +} + +.output-placeholder span { + font-size: 1rem; +} + +.placeholder-hint { + font-size: 0.85rem; + color: var(--text-muted); +} + +.search-highlight { + background: rgba(255, 213, 0, 0.4); + border-radius: 2px; + padding: 0 1px; +} + +.search-highlight.current { + background: rgba(255, 170, 0, 0.7); + outline: 2px solid var(--accent); + outline-offset: 1px; +} diff --git a/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.tsx b/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.tsx new file mode 100644 index 0000000..39f36e2 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.tsx @@ -0,0 +1,55 @@ +import React, { useRef, useEffect } from 'react'; +import { FileText } from 'lucide-react'; +import './TranscriptionContent.css'; + +export interface TranscriptionContentProps { + hasText: boolean; + text: string; + highlightedText: React.JSX.Element[] | null; + currentMatchIndex: number; + matchCount: number; +} + +function TranscriptionContent({ + hasText, + text, + highlightedText, + currentMatchIndex, + matchCount, +}: TranscriptionContentProps): React.JSX.Element { + const contentRef = useRef(null); + + useEffect(() => { + if (matchCount > 0 && contentRef.current) { + const currentMark = contentRef.current.querySelector('.search-highlight.current'); + if (currentMark) { + currentMark.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + }, [currentMatchIndex, matchCount]); + + return ( +
+ {hasText ? ( +
+          {highlightedText || text}
+        
+ ) : ( +
+ + + Transcription will appear here + Select a file and click Transcribe to start +
+ )} +
+ ); +} + +export { TranscriptionContent }; diff --git a/src/renderer/features/transcription/components/TranscriptionContent/index.ts b/src/renderer/features/transcription/components/TranscriptionContent/index.ts new file mode 100644 index 0000000..a86b412 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionContent/index.ts @@ -0,0 +1,2 @@ +export { TranscriptionContent } from './TranscriptionContent'; +export type { TranscriptionContentProps } from './TranscriptionContent'; diff --git a/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.css b/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.css new file mode 100644 index 0000000..135a9da --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.css @@ -0,0 +1,73 @@ +.search-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.search-input { + flex: 1; + padding: 8px 12px; + font-size: 0.9rem; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + outline: none; + transition: all var(--transition-fast); +} + +.search-input:focus { + border-color: var(--accent); +} + +.search-input::placeholder { + color: var(--text-muted); +} + +.search-nav { + display: flex; + align-items: center; + gap: 4px; +} + +.search-count { + font-size: 0.8rem; + color: var(--text-secondary); + padding: 0 8px; + white-space: nowrap; +} + +.search-nav-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + font-size: 0.85rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.search-nav-btn:hover:not(:disabled) { + background: var(--surface-hover); + border-color: var(--border-hover); +} + +.search-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.search-nav-btn.close:hover { + background: var(--danger-light); + border-color: var(--danger); + color: var(--danger); +} diff --git a/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.tsx b/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.tsx new file mode 100644 index 0000000..eb47ab9 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.tsx @@ -0,0 +1,84 @@ +import React, { useRef, useEffect, type ChangeEvent } from 'react'; +import { ChevronUp, ChevronDown, X } from 'lucide-react'; +import './TranscriptionSearch.css'; + +export interface TranscriptionSearchProps { + searchQuery: string; + onSearchChange: (query: string) => void; + currentMatchIndex: number; + totalMatches: number; + onPrevMatch: () => void; + onNextMatch: () => void; + onClose: () => void; +} + +function TranscriptionSearch({ + searchQuery, + onSearchChange, + currentMatchIndex, + totalMatches, + onPrevMatch, + onNextMatch, + onClose, +}: TranscriptionSearchProps): React.JSX.Element { + const searchInputRef = useRef(null); + + useEffect(() => { + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, []); + + const handleSearchChange = (e: ChangeEvent): void => { + onSearchChange(e.target.value); + }; + + return ( +
+ +
+ {searchQuery && ( + + {totalMatches > 0 ? `${currentMatchIndex + 1} of ${totalMatches}` : 'No matches'} + + )} + + + +
+
+ ); +} + +export { TranscriptionSearch }; diff --git a/src/renderer/features/transcription/components/TranscriptionSearch/index.ts b/src/renderer/features/transcription/components/TranscriptionSearch/index.ts new file mode 100644 index 0000000..0a96e92 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionSearch/index.ts @@ -0,0 +1,2 @@ +export { TranscriptionSearch } from './TranscriptionSearch'; +export type { TranscriptionSearchProps } from './TranscriptionSearch'; diff --git a/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css new file mode 100644 index 0000000..c2d10a4 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css @@ -0,0 +1,99 @@ +.output-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + gap: 12px; + background: var(--surface); +} + +.output-header h3 { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.output-meta { + flex: 1; +} + +.word-count { + font-size: 0.8rem; + color: var(--text-muted); +} + +.output-actions { + display: flex; + gap: 8px; +} + +.output-actions .btn-icon { + padding: 6px 12px; + font-size: 0.8rem; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-primary); + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.output-actions .btn-icon:hover { + background: var(--surface-hover); + border-color: var(--border-hover); +} + +.output-actions .btn-icon.success { + background: var(--success-light); + border-color: var(--success); + color: var(--success); +} + +.save-dropdown { + position: relative; +} + +.save-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + min-width: 160px; + z-index: 100; + overflow: hidden; +} + +.save-menu-item { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 10px 14px; + font-size: 0.85rem; + background: transparent; + border: none; + color: var(--text-primary); + cursor: pointer; + transition: background var(--transition-fast); +} + +.save-menu-item:hover { + background: var(--surface-hover); +} + +.format-ext { + color: var(--text-muted); + font-size: 0.75rem; +} + +.output-actions .btn-icon.active { + background: var(--accent-light); + border-color: var(--accent); + color: var(--accent); +} diff --git a/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx new file mode 100644 index 0000000..41a23cc --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx @@ -0,0 +1,115 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Search, Check, Copy, Save } from 'lucide-react'; +import './TranscriptionToolbar.css'; +import type { OutputFormat } from '../../../../types'; +import { OUTPUT_FORMATS } from '../../../../config'; + +export interface TranscriptionToolbarProps { + hasText: boolean; + onCopy: () => void; + onSave: (format: OutputFormat) => void; + copySuccess: boolean; + wordCount: number; + charCount: number; + onToggleSearch: () => void; + isSearchActive: boolean; +} + +function TranscriptionToolbar({ + hasText, + onCopy, + onSave, + copySuccess, + wordCount, + charCount, + onToggleSearch, + isSearchActive, +}: TranscriptionToolbarProps): React.JSX.Element { + const [showSaveMenu, setShowSaveMenu] = useState(false); + const saveMenuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent): void => { + if (saveMenuRef.current && !saveMenuRef.current.contains(e.target as Node)) { + setShowSaveMenu(false); + } + }; + + if (showSaveMenu) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [showSaveMenu]); + + const handleSaveFormat = (format: OutputFormat): void => { + setShowSaveMenu(false); + onSave(format); + }; + + return ( +
+

Transcription

+
+ {hasText && ( + + {wordCount} words ยท {charCount} chars + + )} +
+ {hasText && ( +
+ + +
+ + {showSaveMenu && ( +
+ {OUTPUT_FORMATS.map((format) => ( + + ))} +
+ )} +
+
+ )} +
+ ); +} + +export { TranscriptionToolbar }; diff --git a/src/renderer/features/transcription/components/TranscriptionToolbar/index.ts b/src/renderer/features/transcription/components/TranscriptionToolbar/index.ts new file mode 100644 index 0000000..b680b3e --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionToolbar/index.ts @@ -0,0 +1,2 @@ +export { TranscriptionToolbar } from './TranscriptionToolbar'; +export type { TranscriptionToolbarProps } from './TranscriptionToolbar'; diff --git a/src/renderer/features/transcription/components/index.ts b/src/renderer/features/transcription/components/index.ts index 7335718..9d79be4 100644 --- a/src/renderer/features/transcription/components/index.ts +++ b/src/renderer/features/transcription/components/index.ts @@ -3,3 +3,7 @@ export type { FileDropZoneProps } from './FileDropZone'; export { OutputDisplay } from './OutputDisplay'; export type { OutputDisplayProps } from './OutputDisplay'; + +export * from './TranscriptionToolbar'; +export * from './TranscriptionSearch'; +export * from './TranscriptionContent'; From 840dd0e6ca637dace58c0a2c11dde4edd3264980 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 18 Dec 2025 02:19:21 -0300 Subject: [PATCH 19/22] feat: implement sanitizePath utility and update logging in various components --- .nvmrc | 2 +- src/main/services/whisper.ts | 5 +-- .../__tests__/DonationSection.test.tsx | 27 +++----------- .../__tests__/SystemWarning.test.tsx | 35 ++++--------------- src/renderer/contexts/AppContext.tsx | 22 +++--------- src/renderer/contexts/contexts.ts | 2 -- src/renderer/contexts/hooks.ts | 10 +++--- .../history/__tests__/useHistory.test.ts | 5 ++- .../__tests__/SettingsPanel.test.tsx | 12 ++----- .../__tests__/useTranscription.test.ts | 2 +- .../transcription/hooks/useTranscription.ts | 7 ++-- src/renderer/hooks/useDebugLogs.ts | 1 - src/renderer/services/logger.ts | 11 ++++-- src/renderer/test/setup.ts | 8 ++--- src/renderer/utils/__tests__/storage.test.ts | 9 ++--- src/shared/utils.ts | 6 ++++ 16 files changed, 58 insertions(+), 106 deletions(-) create mode 100644 src/shared/utils.ts diff --git a/.nvmrc b/.nvmrc index 2bd5a0a..1d9b783 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +22.12.0 diff --git a/src/main/services/whisper.ts b/src/main/services/whisper.ts index df9b2e5..d6a3e2b 100644 --- a/src/main/services/whisper.ts +++ b/src/main/services/whisper.ts @@ -15,6 +15,7 @@ import type { GpuInfo, QualityLevel, } from '../../shared/types'; +import { sanitizePath } from '../../shared/utils'; import { detectGpuStatus } from './gpu-detector'; interface WhisperModelInfo { @@ -576,8 +577,8 @@ export function transcribe( if (!text && !vtt) { console.error('Transcription failed: No output generated.', { - txtPath, - vttPath, + txtPath: sanitizePath(txtPath), + vttPath: sanitizePath(vttPath), stdoutLength: stdout.length, }); reject( diff --git a/src/renderer/components/layout/LeftPanel/DonationSection/__tests__/DonationSection.test.tsx b/src/renderer/components/layout/LeftPanel/DonationSection/__tests__/DonationSection.test.tsx index 266d852..b00157a 100644 --- a/src/renderer/components/layout/LeftPanel/DonationSection/__tests__/DonationSection.test.tsx +++ b/src/renderer/components/layout/LeftPanel/DonationSection/__tests__/DonationSection.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { DonationSection } from '../DonationSection'; import { overrideElectronAPI } from '../../../../../test/utils'; +import { logger } from '../../../../../services/logger'; describe('DonationSection', () => { beforeEach(() => { @@ -52,7 +53,6 @@ describe('DonationSection', () => { it('should handle error when openExternal fails for PayPal', async () => { const mockOpenExternal = vi.fn().mockRejectedValue(new Error('Failed to open link')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ openExternal: mockOpenExternal, @@ -64,18 +64,12 @@ describe('DonationSection', () => { fireEvent.click(paypalButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to open donation link:', - expect.any(Error) - ); + expect(logger.error).toHaveBeenCalledWith('Failed to open donation link:', expect.any(Error)); }); - - consoleErrorSpy.mockRestore(); }); it('should handle error when openExternal fails for Buy Me a Coffee', async () => { const mockOpenExternal = vi.fn().mockRejectedValue(new Error('Failed to open link')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ openExternal: mockOpenExternal, @@ -87,18 +81,12 @@ describe('DonationSection', () => { fireEvent.click(coffeeButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to open donation link:', - expect.any(Error) - ); + expect(logger.error).toHaveBeenCalledWith('Failed to open donation link:', expect.any(Error)); }); - - consoleErrorSpy.mockRestore(); }); it('should handle error when trackEvent fails for PayPal', async () => { const mockTrackEvent = vi.fn().mockRejectedValue(new Error('Failed to track')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ trackEvent: mockTrackEvent, @@ -111,18 +99,15 @@ describe('DonationSection', () => { fireEvent.click(paypalButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to track donation click'), expect.any(Error) ); }); - - consoleErrorSpy.mockRestore(); }); it('should handle error when trackEvent fails for Buy Me a Coffee', async () => { const mockTrackEvent = vi.fn().mockRejectedValue(new Error('Failed to track')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ trackEvent: mockTrackEvent, @@ -135,13 +120,11 @@ describe('DonationSection', () => { fireEvent.click(coffeeButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to track donation click'), expect.any(Error) ); }); - - consoleErrorSpy.mockRestore(); }); it('should have proper aria-labels for accessibility', () => { diff --git a/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx b/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx index 1d19837..6a65f9b 100644 --- a/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx +++ b/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { SystemWarning } from '../SystemWarning'; import { overrideElectronAPI } from '../../../../test/utils'; +import { logger } from '../../../../services/logger'; const mockCopyToClipboard = vi.fn(); vi.mock('../../../../hooks/useCopyToClipboard', () => ({ @@ -124,7 +125,6 @@ describe('SystemWarning', () => { it('handles openExternal error when clicking download link', async () => { const mockOpenExternal = vi.fn().mockRejectedValue(new Error('Failed to open')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ openExternal: mockOpenExternal, trackEvent: vi.fn().mockResolvedValue(undefined), @@ -136,15 +136,12 @@ describe('SystemWarning', () => { fireEvent.click(downloadButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to open link:', expect.any(Error)); + expect(logger.error).toHaveBeenCalledWith('Failed to open link:', expect.any(Error)); }); - - consoleErrorSpy.mockRestore(); }); it('handles trackEvent error when clicking download link', async () => { const mockTrackEvent = vi.fn().mockRejectedValue(new Error('Failed to track')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ openExternal: vi.fn().mockResolvedValue(undefined), trackEvent: mockTrackEvent, @@ -156,18 +153,15 @@ describe('SystemWarning', () => { fireEvent.click(downloadButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to track FFmpeg download link click'), expect.any(Error) ); }); - - consoleErrorSpy.mockRestore(); }); it('handles trackEvent error when clicking refresh', async () => { const mockTrackEvent = vi.fn().mockRejectedValue(new Error('Failed to track')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ trackEvent: mockTrackEvent, }); @@ -181,18 +175,15 @@ describe('SystemWarning', () => { }); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( 'Failed to track refresh event:', expect.any(Error) ); }); - - consoleErrorSpy.mockRestore(); }); it('handles trackEvent error when clicking copy', async () => { const mockTrackEvent = vi.fn().mockRejectedValue(new Error('Failed to track')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ getAppInfo: vi.fn().mockResolvedValue({ platform: 'darwin', version: '1.0.0', isDev: true }), trackEvent: mockTrackEvent, @@ -208,17 +199,11 @@ describe('SystemWarning', () => { fireEvent.click(copyButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to track copy event:', - expect.any(Error) - ); + expect(logger.error).toHaveBeenCalledWith('Failed to track copy event:', expect.any(Error)); }); - - consoleErrorSpy.mockRestore(); }); it('handles getAppInfo error', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ getAppInfo: vi.fn().mockRejectedValue(new Error('Failed to get info')), }); @@ -226,13 +211,8 @@ describe('SystemWarning', () => { render(); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to get platform info:', - expect.any(Error) - ); + expect(logger.error).toHaveBeenCalledWith('Failed to get platform info:', expect.any(Error)); }); - - consoleErrorSpy.mockRestore(); }); it('retries onRefresh if first attempt fails (returns false)', async () => { @@ -259,7 +239,6 @@ describe('SystemWarning', () => { }); it('handles onRefresh error', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); mockOnRefresh.mockRejectedValue(new Error('Refresh failed')); render(); @@ -277,7 +256,5 @@ describe('SystemWarning', () => { await waitFor(() => { expect(refreshButton).not.toBeDisabled(); }); - - consoleErrorSpy.mockRestore(); }); }); diff --git a/src/renderer/contexts/AppContext.tsx b/src/renderer/contexts/AppContext.tsx index 3aac94e..08ef10b 100644 --- a/src/renderer/contexts/AppContext.tsx +++ b/src/renderer/contexts/AppContext.tsx @@ -6,14 +6,12 @@ import type { HistoryItem } from '../types'; import { ThemeContext, HistoryContext, - TranscriptionContext, TranscriptionStateContext, TranscriptionActionsContext, } from './contexts'; import type { ThemeContextValue, HistoryContextValue, - TranscriptionContextValue, TranscriptionStateContextValue, TranscriptionActionsContextValue, } from './types'; @@ -176,24 +174,14 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { ] ); - const transcriptionContextValue = useMemo( - () => ({ - ...transcriptionStateValue, - ...transcriptionActionsValue, - }), - [transcriptionStateValue, transcriptionActionsValue] - ); - return ( - - - - {children} - - - + + + {children} + + ); diff --git a/src/renderer/contexts/contexts.ts b/src/renderer/contexts/contexts.ts index e186ff2..21bf34d 100644 --- a/src/renderer/contexts/contexts.ts +++ b/src/renderer/contexts/contexts.ts @@ -2,14 +2,12 @@ import { createContext } from 'react'; import type { ThemeContextValue, HistoryContextValue, - TranscriptionContextValue, TranscriptionStateContextValue, TranscriptionActionsContextValue, } from './types'; export const ThemeContext = createContext(null); export const HistoryContext = createContext(null); -export const TranscriptionContext = createContext(null); export const TranscriptionStateContext = createContext(null); export const TranscriptionActionsContext = createContext( null diff --git a/src/renderer/contexts/hooks.ts b/src/renderer/contexts/hooks.ts index 771aec0..f0380d8 100644 --- a/src/renderer/contexts/hooks.ts +++ b/src/renderer/contexts/hooks.ts @@ -2,7 +2,6 @@ import { useContext } from 'react'; import { ThemeContext, HistoryContext, - TranscriptionContext, TranscriptionStateContext, TranscriptionActionsContext, } from './contexts'; @@ -31,11 +30,14 @@ export function useAppHistory(): HistoryContextValue { } export function useAppTranscription(): TranscriptionContextValue { - const context = useContext(TranscriptionContext); - if (!context) { + const state = useContext(TranscriptionStateContext); + const actions = useContext(TranscriptionActionsContext); + + if (!state || !actions) { throw new Error('useAppTranscription must be used within AppProvider'); } - return context; + + return { ...state, ...actions }; } export function useAppTranscriptionState(): TranscriptionStateContextValue { diff --git a/src/renderer/features/history/__tests__/useHistory.test.ts b/src/renderer/features/history/__tests__/useHistory.test.ts index 4e51af0..eb6dbb8 100644 --- a/src/renderer/features/history/__tests__/useHistory.test.ts +++ b/src/renderer/features/history/__tests__/useHistory.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { renderHook, act } from '@testing-library/react'; +import { logger } from '../../../services/logger'; import { useHistory } from '@/features/history'; import type { HistoryItem } from '@/types'; import { createMockHistoryItem, createHistoryItems } from '@/test/fixtures'; @@ -190,7 +191,6 @@ describe('useHistory', () => { }); it('should handle localStorage save failure gracefully', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const originalSetItem = localStorage.setItem.bind(localStorage); localStorage.setItem = vi.fn().mockImplementation(() => { @@ -203,9 +203,8 @@ describe('useHistory', () => { result.current.addHistoryItem(createMockHistoryItem()); }); - expect(consoleSpy).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); localStorage.setItem = originalSetItem; - consoleSpy.mockRestore(); }); }); diff --git a/src/renderer/features/settings/components/SettingsPanel/__tests__/SettingsPanel.test.tsx b/src/renderer/features/settings/components/SettingsPanel/__tests__/SettingsPanel.test.tsx index 0137a6c..327e61b 100644 --- a/src/renderer/features/settings/components/SettingsPanel/__tests__/SettingsPanel.test.tsx +++ b/src/renderer/features/settings/components/SettingsPanel/__tests__/SettingsPanel.test.tsx @@ -4,6 +4,7 @@ import { SettingsPanel } from '../SettingsPanel'; import type { TranscriptionSettings } from '../../../../../types'; import { overrideElectronAPI } from '../../../../../test/utils'; import { MOCK_SETTINGS, createMockModels, MOCK_GPU_INFO } from '../../../../../test/fixtures'; +import { logger } from '../../../../../services/logger'; describe('SettingsPanel', () => { const mockSettings = MOCK_SETTINGS; @@ -514,7 +515,6 @@ describe('SettingsPanel', () => { it('handles download model failure gracefully', async () => { const downloadModel = vi.fn().mockRejectedValue(new Error('Download failed')); const listModels = vi.fn().mockResolvedValue({ models: mockModels }); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ listModels, @@ -534,10 +534,8 @@ describe('SettingsPanel', () => { fireEvent.click(downloadButton); await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith('Failed to download model:', expect.any(Error)); + expect(logger.error).toHaveBeenCalledWith('Failed to download model:', expect.any(Error)); }); - - consoleSpy.mockRestore(); }); it('shows GPU status as unavailable when GPU is not available', async () => { @@ -558,8 +556,6 @@ describe('SettingsPanel', () => { }); it('handles model info loading failure', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - overrideElectronAPI({ listModels: vi.fn().mockRejectedValue(new Error('Failed to load')), getGpuStatus: vi.fn().mockRejectedValue(new Error('Failed to get GPU')), @@ -571,9 +567,7 @@ describe('SettingsPanel', () => { expect(screen.getByLabelText('Select Whisper model')).toBeInTheDocument(); }); - expect(consoleSpy).toHaveBeenCalledWith('Failed to load model info:', expect.any(Error)); - - consoleSpy.mockRestore(); + expect(logger.error).toHaveBeenCalledWith('Failed to load model info:', expect.any(Error)); }); it('displays download progress without remaining time', async () => { diff --git a/src/renderer/features/transcription/__tests__/useTranscription.test.ts b/src/renderer/features/transcription/__tests__/useTranscription.test.ts index 2a6e522..e45f238 100644 --- a/src/renderer/features/transcription/__tests__/useTranscription.test.ts +++ b/src/renderer/features/transcription/__tests__/useTranscription.test.ts @@ -552,7 +552,7 @@ describe('useTranscription', () => { expect(result.current.isTranscribing).toBe(false); }); - expect(result.current.error).toContain('no output'); + expect(result.current.error).toContain('Electron API not available'); }); it('should handle transcription progress updates', async () => { diff --git a/src/renderer/features/transcription/hooks/useTranscription.ts b/src/renderer/features/transcription/hooks/useTranscription.ts index ef8a4b4..dc577ed 100644 --- a/src/renderer/features/transcription/hooks/useTranscription.ts +++ b/src/renderer/features/transcription/hooks/useTranscription.ts @@ -16,6 +16,7 @@ import { saveFile, } from '../../../services/electronAPI'; import { logger } from '../../../services/logger'; +import { sanitizePath } from '../../../../shared/utils'; interface UseTranscriptionOptions { onHistoryAdd?: (item: HistoryItem) => void; @@ -109,7 +110,7 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran logger.info('File selected', { name: file.name, - path: file.path, + path: sanitizePath(file.path), size: file.size, }); @@ -137,7 +138,7 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran setTranscriptionStartTime(Date.now()); logger.info('Starting transcription', { - file: selectedFile.path, + file: sanitizePath(selectedFile.path), model: settings.model, language: settings.language, }); @@ -271,7 +272,7 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran if (result?.success && result.filePath) { setProgress({ percent: 100, status: `Saved to ${result.filePath}` }); - logger.info('File saved', { path: result.filePath, format }); + logger.info('File saved', { path: sanitizePath(result.filePath), format }); scheduleProgressReset(APP_CONFIG.SAVE_SUCCESS_MESSAGE_DURATION); } else if (result?.error) { setError(`Failed to save: ${result.error}`); diff --git a/src/renderer/hooks/useDebugLogs.ts b/src/renderer/hooks/useDebugLogs.ts index 584b4ba..f47f1a3 100644 --- a/src/renderer/hooks/useDebugLogs.ts +++ b/src/renderer/hooks/useDebugLogs.ts @@ -35,7 +35,6 @@ function getSystemInfo(osVersion?: string): string { `- **Platform**: ${navigator.platform}`, `- **OS Version**: ${osVersion || 'Unknown'}`, `- **User Agent**: ${navigator.userAgent}`, - `- **User Agent**: ${navigator.userAgent}`, `- **Language**: ${navigator.language}`, `- **Online**: ${navigator.onLine}`, `- **Timestamp**: ${new Date().toISOString()}`, diff --git a/src/renderer/services/logger.ts b/src/renderer/services/logger.ts index 2cffc0f..ee91b2b 100644 --- a/src/renderer/services/logger.ts +++ b/src/renderer/services/logger.ts @@ -14,7 +14,14 @@ const logBuffer: LogEntry[] = []; function formatMessage(level: LogLevel, message: string, data?: unknown): string { const timestamp = new Date().toISOString(); const prefix = `[${timestamp}] [${level.toUpperCase()}]`; - return data !== undefined ? `${prefix} ${message}` : `${prefix} ${message}`; + if (data !== undefined) { + try { + return `${prefix} ${message} ${JSON.stringify(data)}`; + } catch { + return `${prefix} ${message} [Data]`; + } + } + return `${prefix} ${message}`; } function storeLogEntry(level: LogLevel, message: string, data?: unknown): void { @@ -35,7 +42,7 @@ function storeLogEntry(level: LogLevel, message: string, data?: unknown): void { function log(level: LogLevel, message: string, data?: unknown): void { storeLogEntry(level, message, data); - if (!isDev) return; + if (!isDev && level !== 'error') return; const formattedMessage = formatMessage(level, message, data); diff --git a/src/renderer/test/setup.ts b/src/renderer/test/setup.ts index feac033..dcbb7b8 100644 --- a/src/renderer/test/setup.ts +++ b/src/renderer/test/setup.ts @@ -71,10 +71,10 @@ vi.stubGlobal('localStorage', { vi.mock('../services/logger', () => ({ logger: { - debug: vi.fn((...args: unknown[]) => console.debug(...args)), - info: vi.fn((...args: unknown[]) => console.info(...args)), - warn: vi.fn((...args: unknown[]) => console.warn(...args)), - error: vi.fn((...args: unknown[]) => console.error(...args)), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), isEnabled: vi.fn(() => true), }, })); diff --git a/src/renderer/utils/__tests__/storage.test.ts b/src/renderer/utils/__tests__/storage.test.ts index 46c8b32..85aac0c 100644 --- a/src/renderer/utils/__tests__/storage.test.ts +++ b/src/renderer/utils/__tests__/storage.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import { logger } from '../../services/logger'; import { getStorageItem, setStorageItem, @@ -152,7 +153,6 @@ describe('storage', () => { describe('error handling', () => { it('should handle localStorage.setItem failure for setStorageItem', () => { const originalSetItem = localStorage.setItem.bind(localStorage); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); localStorage.setItem = vi.fn().mockImplementation(() => { throw new Error('Storage quota exceeded'); @@ -161,15 +161,13 @@ describe('storage', () => { const result = setStorageItem('whisperdesk_theme' as StorageKey, { data: 'test' }); expect(result).toBe(false); - expect(consoleSpy).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); localStorage.setItem = originalSetItem; - consoleSpy.mockRestore(); }); it('should handle localStorage.setItem failure for setStorageString', () => { const originalSetItem = localStorage.setItem.bind(localStorage); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); localStorage.setItem = vi.fn().mockImplementation(() => { throw new Error('Storage quota exceeded'); @@ -178,10 +176,9 @@ describe('storage', () => { const result = setStorageString('whisperdesk_theme' as StorageKey, 'test'); expect(result).toBe(false); - expect(consoleSpy).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); localStorage.setItem = originalSetItem; - consoleSpy.mockRestore(); }); it('should handle localStorage.getItem failure for getStorageItem', () => { diff --git a/src/shared/utils.ts b/src/shared/utils.ts new file mode 100644 index 0000000..b00994c --- /dev/null +++ b/src/shared/utils.ts @@ -0,0 +1,6 @@ +export function sanitizePath(filePath: string): string { + if (!filePath) return ''; + + const parts = filePath.split(/[/\\]/); + return parts.pop() || ''; +} From cb9ed0ba20bcbc298d798a5df183403da8bebbf9 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 18 Dec 2025 12:51:34 -0300 Subject: [PATCH 20/22] refactor: update component exports to use named exports for consistency --- .../layout/LeftPanel/DonationSection/DonationSection.tsx | 4 +++- .../layout/LeftPanel/ErrorMessage/ErrorMessage.tsx | 4 +++- .../LeftPanel/TranscriptionActions/TranscriptionActions.tsx | 6 +++--- .../TranscriptionProgress/TranscriptionProgress.tsx | 4 +++- .../components/UpdateNotification/UpdateNotification.tsx | 4 +++- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx b/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx index 3ae84a3..27b9350 100644 --- a/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx +++ b/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx @@ -6,7 +6,7 @@ import './DonationSection.css'; const PAYPAL_DONATION_URL = 'https://www.paypal.com/donate/?hosted_button_id=HTJXGMEGMWWD6'; const BUY_ME_A_COFFEE_URL = 'https://www.buymeacoffee.com/pedrovsiqueira'; -export function DonationSection(): React.JSX.Element { +function DonationSection(): React.JSX.Element { const handleDonationClick = async (url: string, type: string) => { trackEvent('donation_clicked', { location: 'left_panel', type }).catch((error) => { logger.error(`Failed to track donation click (type: ${type}, url: ${url}):`, error); @@ -41,3 +41,5 @@ export function DonationSection(): React.JSX.Element {
); } + +export { DonationSection }; diff --git a/src/renderer/components/layout/LeftPanel/ErrorMessage/ErrorMessage.tsx b/src/renderer/components/layout/LeftPanel/ErrorMessage/ErrorMessage.tsx index 18bc3f7..2d8760a 100644 --- a/src/renderer/components/layout/LeftPanel/ErrorMessage/ErrorMessage.tsx +++ b/src/renderer/components/layout/LeftPanel/ErrorMessage/ErrorMessage.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { AlertTriangle } from 'lucide-react'; import { useAppTranscription } from '../../../../contexts'; -export function ErrorMessage(): React.JSX.Element | null { +function ErrorMessage(): React.JSX.Element | null { const { error } = useAppTranscription(); if (!error) { @@ -15,3 +15,5 @@ export function ErrorMessage(): React.JSX.Element | null {
); } + +export { ErrorMessage }; diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx b/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx index 44b1fec..132e6ec 100644 --- a/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx +++ b/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx @@ -6,9 +6,7 @@ export interface TranscriptionActionsProps { isFFmpegAvailable: boolean | null; } -export function TranscriptionActions({ - isFFmpegAvailable, -}: TranscriptionActionsProps): React.JSX.Element { +function TranscriptionActions({ isFFmpegAvailable }: TranscriptionActionsProps): React.JSX.Element { const { selectedFile, isTranscribing, modelDownloaded, handleTranscribe, handleCancel } = useAppTranscription(); @@ -44,3 +42,5 @@ export function TranscriptionActions({ ); } + +export { TranscriptionActions }; diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionProgress/TranscriptionProgress.tsx b/src/renderer/components/layout/LeftPanel/TranscriptionProgress/TranscriptionProgress.tsx index 2cd8d89..be0f406 100644 --- a/src/renderer/components/layout/LeftPanel/TranscriptionProgress/TranscriptionProgress.tsx +++ b/src/renderer/components/layout/LeftPanel/TranscriptionProgress/TranscriptionProgress.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { ProgressBar } from '../../../ui'; import { useAppTranscription } from '../../../../contexts'; -export function TranscriptionProgress(): React.JSX.Element | null { +function TranscriptionProgress(): React.JSX.Element | null { const { isTranscribing, progress, transcriptionStartTime } = useAppTranscription(); if (!isTranscribing && !progress.status) { @@ -18,3 +18,5 @@ export function TranscriptionProgress(): React.JSX.Element | null { /> ); } + +export { TranscriptionProgress }; diff --git a/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.tsx b/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.tsx index 5a2e162..7533701 100644 --- a/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.tsx +++ b/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.tsx @@ -3,7 +3,7 @@ import { convertHtmlToText, formatFileSize } from '../../../../utils'; import { X, AlertTriangle, Sparkles, Download, CheckCircle } from 'lucide-react'; import './UpdateNotification.css'; -export function UpdateNotification() { +function UpdateNotification() { const { updateStatus, isDownloading, @@ -139,3 +139,5 @@ export function UpdateNotification() { ); } + +export { UpdateNotification }; From 3d616dc51c5f048f1a447740ce505159e736f12f Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 18 Dec 2025 13:02:13 -0300 Subject: [PATCH 21/22] feat: add TypeScript build artifacts to .gitignore for improved project structure --- .gitignore | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 90d1628..50135ec 100644 --- a/.gitignore +++ b/.gitignore @@ -68,4 +68,12 @@ DEVELOPMENT_PLAN.md # Copilot Instructions .github/copilot-instructions.md -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo + +# TypeScript build artifacts (config files) +vite.config.js +vite.config.d.ts +tsup.config.js +tsup.config.d.ts +vitest.config.js +vitest.config.d.ts \ No newline at end of file From bdc22324ee87f4ca8cabce29c4484add6591eba7 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 18 Dec 2025 13:03:48 -0300 Subject: [PATCH 22/22] fix: update DMG background color for improved visibility --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1dd07ae..2e55a39 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ }, "dmg": { "title": "WhisperDesk", - "backgroundColor": "#1a1a2e", + "backgroundColor": "#f5f5f7", "window": { "width": 540, "height": 380