diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..7ad8191 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,236 @@ +# --------------------------------------------------------------------------- +# CodeRabbit Configuration for mindfiredigital/markdown-reader +# +# Stack : Electron + React + TypeScript + Vite + Tailwind CSS +# Tooling : pnpm + Vitest + ESLint + Prettier + electron-vite +# Pattern : Electron desktop app monorepo with main, preload, renderer, shared packages, and Docusaurus docs +# +# Validate : https://docs.coderabbit.ai/configuration/yaml-validator +# Reference: https://docs.coderabbit.ai/reference/configuration +# --------------------------------------------------------------------------- + +language: 'en-US' + +tone_instructions: 'Senior Electron/React/TS reviewer. Be concise. Prioritise IPC/preload boundaries, markdown sanitisation, local file safety, a11y, tests, packaging, and CI. Flag real risks; suggest concrete fixes.' + +early_access: false + +reviews: + profile: 'assertive' + request_changes_workflow: true + + high_level_summary: true + high_level_summary_instructions: > + Summarise the PR in concise bullets grouped by: + Electron main/preload, renderer UI, markdown rendering, shared packages, + tests, tooling/CI, docs, and packaging. End with "Breaking changes: None" + unless a public API, IPC contract, file behaviour, shortcut, or packaging + change is breaking. + high_level_summary_in_walkthrough: false + + collapse_walkthrough: false + changed_files_summary: true + sequence_diagrams: true + estimate_code_review_effort: true + assess_linked_issues: true + related_issues: true + related_prs: true + + suggested_labels: true + auto_apply_labels: false + suggested_reviewers: true + auto_assign_reviewers: false + + commit_status: true + fail_commit_status: false + review_status: true + review_details: true + + poem: false + in_progress_fortune: false + + auto_review: + enabled: true + base_branches: + - main + - dev + + path_filters: + - '!out/**' + - '!dist/**' + - '!build/**' + - '!coverage/**' + - '!release/**' + - '!node_modules/**' + - '!docs/.docusaurus/**' + - '!docs/build/**' + - '!pnpm-lock.yaml' + - '!**/*.snap' + - '!**/__snapshots__/**' + - '!**/*.{png,jpg,jpeg,gif,webp,ico,icns,mp4,zip}' + + path_instructions: + - path: 'apps/main-processor/src/**/*.ts' + instructions: | + Review as Electron main-process code. + - IPC handlers must use shared constants and validate renderer input. + - File/folder access must guard path traversal, missing files, permissions, symlinks, and deleted watched files. + - Watchers, menus, dialogs, and IPC listeners must be cleaned up. + - Do not expose Node/Electron internals or unrestricted filesystem access. + - Export/update/download flows must sanitize content, close resources, and avoid executing embedded scripts. + + - path: 'apps/preload/src/**/*.ts' + instructions: | + Review as a strict preload boundary. + - Expose only typed contextBridge APIs, never raw ipcRenderer. + - Use shared IPC constants and shared payload/result types. + - Listener methods must return unsubscribe functions. + - Reject broad channel names, arbitrary invoke/send wrappers, and any-typed payloads. + + - path: 'apps/renderer/src/**/*.{ts,tsx}' + instructions: | + Review as React renderer code. + - Keep components typed, accessible, keyboard-friendly, and resilient to missing preload APIs. + - Effects must have correct dependencies and cleanup. + - Handle loading, empty, error, stale-response, and rejected-promise states. + - Do not import Node-only modules into renderer code. + - Avoid unnecessary derived state, unsafe globals, and broad any types. + + - path: 'apps/renderer/src/**/{renderer,markdown,utils}/**/*.{ts,tsx}' + instructions: | + Review markdown rendering carefully. + - Sanitize raw HTML, links, images, Mermaid, KaTeX, anchors, and exported content. + - Block script execution, javascript: URLs, unsafe inline handlers, and unsafe local file references. + - Heading IDs and TOC entries must be stable and collision-safe. + - Mermaid/KaTeX/code highlighting failures should not break the whole document. + - Add tests for unsafe HTML, malformed markdown, links, images, code blocks, Mermaid, and KaTeX when changed. + + - path: 'apps/renderer/src/**/*.{css,tsx}' + instructions: | + Review UI, theme, and accessibility. + - Interactive controls need semantic elements, visible focus, and keyboard access. + - Theme changes must preserve readable contrast in light and dark modes. + - Markdown prose must remain readable for tables, code, blockquotes, links, lists, and images. + - Prefer existing tokens/classes over ad hoc inline styling. + + - path: 'packages/shared-*/src/**/*.ts' + instructions: | + Review shared package contracts. + - IPC constants, menu constants, shortcuts, and shared types are public contracts. + - New IPC constants must have handler, preload wrapper, renderer usage, and tests. + - Breaking type/API changes must be called out clearly. + - Prefer precise types over string/object/unknown/any shapes. + + - path: '**/*.{test,spec}.{ts,tsx}' + instructions: | + Review tests. + - Cover success and failure paths, especially IPC, filesystem, markdown rendering, search, settings, tabs, and exports. + - Use isolated temp directories for disk tests and clean them up. + - Mock Electron/preload APIs explicitly. + - Prefer Testing Library user-event and getByRole for UI tests. + + - path: '.github/workflows/**/*.yml' + instructions: | + Review CI/CD. + - Actions should use version tags, not @main. + - Secrets must use ${{ secrets.* }} and never be hardcoded. + - CI should install with pinned pnpm, then run lint, typecheck, tests, build, and package checks. + - Security scans should fail for high/critical findings unless justified. + + - path: 'electron.vite.config.ts' + instructions: | + Review Electron/Vite build separation. + - Main, preload, and renderer entry points must stay separated. + - Main/preload should externalize Node/Electron dependencies where needed. + - Renderer must not bundle Node-only or Electron main-process modules. + - Production sourcemap/minify/external settings must be intentional. + + - path: 'electron-builder.ts' + instructions: | + Review packaging. + - Check appId, productName, files, asar, icons, targets, file associations, artifact names, and publish settings. + - Exclude source-only, test, coverage, cache, and map files from releases. + - Signing/notarisation/update config must not hardcode secrets. + - Platform targets should match expected Windows, macOS, and Linux release formats. + + - path: '**/package.json' + instructions: | + Review scripts and dependencies. + - Scripts for lint, typecheck, test, coverage, build, and dist must fail on errors. + - Dependencies should live in the package that imports them. + - Runtime imports must not be placed only in devDependencies. + - Electron, Vite, React, TypeScript, Tailwind, and testing upgrades need compatibility attention. + + - path: 'tsconfig*.json' + instructions: | + Review TypeScript config. + - Keep strict type safety enabled. + - Module, target, moduleResolution, paths, and includes must match electron-vite and workspace boundaries. + - Renderer configs should include DOM types; main/preload should not accidentally depend on browser globals. + + - path: 'eslint.config.*' + instructions: | + Review lint config. + - TypeScript parsing should cover workspace TS/TSX files. + - React hooks rules must apply to renderer code. + - Avoid disabling rules that hide runtime errors or weaken type safety. + + - path: 'docs/**/*.{md,mdx,ts,tsx}' + instructions: | + Review docs. + - Docs must match current shortcuts, markdown support, export behaviour, install steps, and privacy/offline claims. + - Code blocks need language tags. + - Links and images should resolve. + - Docusaurus components must guard browser-only APIs during static build. + + - path: 'pnpm-workspace.yaml' + instructions: | + Review workspace config. + - Workspace globs must intentionally include apps, packages, and docs. + - allowBuilds entries should stay minimal and justified. + + tools: + eslint: + enabled: true + markdownlint: + enabled: true + actionlint: + enabled: true + gitleaks: + enabled: true + htmlhint: + enabled: true + checkov: + enabled: true + languagetool: + enabled: true + enabled_rules: + - 'OXFORD_COMMA' + - 'EN_QUOTES' + - 'COMMA_PARENTHESIS_WHITESPACE' + disabled_categories: + - 'TYPOGRAPHY' + yamllint: + enabled: true + ast-grep: + essential_rules: true + biome: + enabled: false + +chat: + auto_reply: true + +knowledge_base: + opt_out: false + web_search: + enabled: true + code_guidelines: + enabled: true + filePatterns: + - 'README.md' + - 'CONTRIBUTING.md' + - 'SECURITY.md' + - 'docs/**/*.md' + - 'docs/**/*.mdx' + learnings: + scope: auto \ No newline at end of file diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index 49e84dc..75e1f97 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -20,9 +20,7 @@ jobs: node-version: 22 - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 10 + uses: pnpm/action-setup@v4 - name: Install dependencies run: pnpm install --frozen-lockfile @@ -40,4 +38,35 @@ jobs: run: pnpm test:coverage - name: Build Electron app - run: pnpm dist \ No newline at end of file + run: pnpm dist + + security: + name: Security Audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: pnpm audit + run: pnpm audit --audit-level=high + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: fs + scan-ref: . + severity: CRITICAL,HIGH + format: table + exit-code: 1 diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 422f004..064f47f 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -20,9 +20,7 @@ jobs: node-version: 22 - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 10 + uses: pnpm/action-setup@v4 - name: Install dependencies run: pnpm install --frozen-lockfile @@ -40,4 +38,35 @@ jobs: run: pnpm test:coverage - name: Build Electron app - run: pnpm dist \ No newline at end of file + run: pnpm dist + + security: + name: Security Audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: pnpm audit + run: pnpm audit --audit-level=high + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: fs + scan-ref: . + severity: CRITICAL,HIGH + format: table + exit-code: 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 952dc5b..90fd5bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,9 @@ on: permissions: contents: write + +env: + HUSKY: 0 jobs: # add change set diff --git a/.gitignore b/.gitignore index ea064ff..3073ad1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,40 @@ -node_modules -dist -build +# Dependencies +node_modules/ + +# Build outputs +out/ +dist/ +build/ +release/ +*.tgz +.vite/ + +# Electron builder +.cache/ + +# Environment .env -out -coverage -release -.vite/ \ No newline at end of file +.env.local +.env.*.local + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# OS artifacts +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Test coverage +coverage/ + +# Docusaurus +docs/.docusaurus/ +docs/build/ diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..a8ad12c --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +pnpm test \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 44b5b73..2efcb8d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,9 @@ node_modules dist .next -build \ No newline at end of file +build +out/ +release/ +coverage/ +docs/.docusaurus/ +docs/build/ \ No newline at end of file diff --git a/README.md b/README.md index edf8f86..366fd10 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,8 @@ markdown-reader is a dedicated native desktop Markdown reader: - Links - Footnotes - Inline HTML -- KaTeX math - Mermaid diagrams +- KaTeX math --- diff --git a/apps/main-processor/tests/cli.test.ts b/apps/main-processor/__tests__/cli.test.ts similarity index 100% rename from apps/main-processor/tests/cli.test.ts rename to apps/main-processor/__tests__/cli.test.ts diff --git a/apps/main-processor/__tests__/docx.test.ts b/apps/main-processor/__tests__/docx.test.ts new file mode 100644 index 0000000..4149ec0 --- /dev/null +++ b/apps/main-processor/__tests__/docx.test.ts @@ -0,0 +1,41 @@ +/* eslint-disable no-empty */ +import { describe, it, expect, afterAll } from 'vitest'; +import { existsSync, unlinkSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { exportDOCX } from '../src/export/exportDocx'; + +const docxPath = join(tmpdir(), 'markdown-export.docx'); +const invalidPath = join(tmpdir(), 'missing-folder-test', 'out.docx'); + +describe('export docx', () => { + afterAll(() => { + try { + unlinkSync(docxPath); + } catch {} + }); + + it('it should create a DOCX file at the specified output path', async () => { + await exportDOCX( + '

Test DOCX

Hello DOCX export.

', + 'body { color: black; }', + docxPath + ); + + expect(existsSync(docxPath)).toBe(true); + }); + + it('it should create a non-empty DOCX file', () => { + const stats = statSync(docxPath); + + expect(stats.size).toBeGreaterThan(100); + }); + + it('it should throw when output path is invalid', async () => { + await expect(exportDOCX('

Test

', '', invalidPath)).rejects.toThrow(); + }); + + it('it should handle empty HTML without crashing', async () => { + await expect(exportDOCX('', 'body { color: black; }', docxPath)).resolves.not.toThrow(); + }); +}); diff --git a/apps/main-processor/__tests__/export.test.ts b/apps/main-processor/__tests__/export.test.ts new file mode 100644 index 0000000..6c5ebac --- /dev/null +++ b/apps/main-processor/__tests__/export.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { buildDocument } from '../src/export/buildDocument'; +import { sanitizeCss } from '../src/export/sanitizeCss'; +import { getImage } from '../src/export/getImage'; +import { inlineImages } from '../src/export/inlineImage'; + +describe('build html document', () => { + it('wraps content in a valid HTML5 document shell', () => { + const html = buildDocument('

Hello

', ''); + expect(html).toContain(''); + expect(html).toContain('Hello'); + }); + + it('inlines the provided CSS inside a + + + +
+ ${htmlBody} +
+ +`; +} diff --git a/apps/main-processor/src/utils/helper/ipc-path-resolver.ts b/apps/main-processor/src/utils/helper/ipc-path-resolver.ts new file mode 100644 index 0000000..916556c --- /dev/null +++ b/apps/main-processor/src/utils/helper/ipc-path-resolver.ts @@ -0,0 +1,71 @@ +import path from 'path'; +import { realpath, stat } from 'node:fs/promises'; +import { + validatePath, + isPathInside, + ALLOWED_MARKDOWN_EXTENSIONS, + allowedFolderRoots, + allowedMarkdownFiles, +} from '../constants/ipc-validation'; + +/* Validates, resolves symlink and ensures a path is a valid Markdown file inside an optional root directory */ +export async function resolveMarkdownFilePath( + filePath: string, + allowedRoot?: string +): Promise { + if (!validatePath(filePath)) { + throw new Error('Invalid file path'); + } + const realFilePath = await realpath(path.resolve(filePath)); + if (!validatePath(realFilePath)) { + throw new Error('Invalid file path'); + } + const fileStats = await stat(realFilePath); + if (!fileStats.isFile()) { + throw new Error('Path is not a file'); + } + const ext = path.extname(realFilePath).toLowerCase(); + if (!ALLOWED_MARKDOWN_EXTENSIONS.has(ext)) { + throw new Error(`Disallowed file extension: ${ext}`); + } + if (allowedRoot) { + const realAllowedRoot = await realpath(path.resolve(allowedRoot)); + if (!isPathInside(realFilePath, realAllowedRoot)) { + throw new Error('Path escapes allowed directory'); + } + } + return realFilePath; +} + +/*Validates, resolves symlinks and ensures a path points to an existing directory.*/ +export async function resolveDirectoryPath(folderPath: string): Promise { + if (!validatePath(folderPath)) { + throw new Error('Invalid folder path'); + } + const realFolderPath = await realpath(path.resolve(folderPath)); + if (!validatePath(realFolderPath)) { + throw new Error('Invalid folder path'); + } + const folderStats = await stat(realFolderPath); + if (!folderStats.isDirectory()) { + throw new Error('Path is not a directory'); + } + return realFolderPath; +} + +/*Resolves a Markdown file path against a dynamic set of allowed root folders throwing an error if it matches none*/ +export async function resolveWatchedMarkdownPath(filePath: string): Promise { + const safeFilePath = await resolveMarkdownFilePath(filePath); + + if (allowedMarkdownFiles.has(safeFilePath)) { + return safeFilePath; + } + for (const allowedRoot of allowedFolderRoots) { + try { + return await resolveMarkdownFilePath(filePath, allowedRoot); + } catch { + continue; + } + } + throw new Error('Path escapes allowed directory'); +} diff --git a/apps/main-processor/src/utils/helper/menu-helper.ts b/apps/main-processor/src/utils/helper/menu-helper.ts index df0781e..50e698e 100644 --- a/apps/main-processor/src/utils/helper/menu-helper.ts +++ b/apps/main-processor/src/utils/helper/menu-helper.ts @@ -1,9 +1,9 @@ import { BrowserWindow, BaseWindow, MenuItem, KeyboardEvent } from 'electron'; -export function createMenuSender(eventName: string) { +export function createMenuSender(eventName: string, payload?: unknown) { return (_menuItem: MenuItem, window: BaseWindow | undefined, _event: KeyboardEvent): void => { if (window instanceof BrowserWindow) { - window.webContents.send(eventName); + window.webContents.send(eventName, payload); } }; } diff --git a/apps/main-processor/src/utils/helper/path-helper.ts b/apps/main-processor/src/utils/helper/path-helper.ts index b69f56d..1313794 100644 --- a/apps/main-processor/src/utils/helper/path-helper.ts +++ b/apps/main-processor/src/utils/helper/path-helper.ts @@ -1,6 +1,7 @@ import { join } from 'node:path'; import { app } from 'electron'; -import { MARKDOWN_FILE_PATTERN } from '../path-constants'; +import { MARKDOWN_FILE_PATTERN } from '../constants/path-constants'; +import { fileURLToPath } from 'node:url'; export function getRecentFilePath(): string { return join(app.getPath('userData'), 'recent.json'); } @@ -8,3 +9,10 @@ export function getRecentFilePath(): string { export function isMarkdownFile(fileName: string): boolean { return MARKDOWN_FILE_PATTERN.test(fileName); } + +export function normaliseImagePath(src: string): string { + if (src.startsWith('file://')) { + return fileURLToPath(src); + } + return src; +} diff --git a/apps/main-processor/src/utils/helper/setting-helper.ts b/apps/main-processor/src/utils/helper/setting-helper.ts new file mode 100644 index 0000000..5e96b3d --- /dev/null +++ b/apps/main-processor/src/utils/helper/setting-helper.ts @@ -0,0 +1,112 @@ +import { app } from 'electron'; +import path from 'path'; +import { mkdir, open, rename, unlink, writeFile } from 'node:fs/promises'; +import { AppSettings } from '@package/shared-types'; +import { THEMES } from '@package/shared-constants'; +import { SETTINGS_KEYS, READING_WIDTHS } from '../constants/setting-constants'; + +/* Gives file system path for settings.json */ +export function getSettingsPath(): string { + return path.join(app.getPath('userData'), 'settings.json'); +} + +/* To validate the settings preference before saving */ +export function validateSettings(partial: Partial): Partial { + if (!partial || typeof partial !== 'object' || Array.isArray(partial)) { + throw new Error('Invalid settings object'); + } + + const validated: Partial = {}; + for (const [key, value] of Object.entries(partial)) { + if (!SETTINGS_KEYS.has(key as keyof AppSettings)) { + throw new Error(`Unknown settings key: ${key}`); + } + + if (key === 'theme') { + if (typeof value !== 'string' || !THEMES.includes(value as AppSettings['theme'])) { + throw new Error('Invalid theme'); + } + validated.theme = value as AppSettings['theme']; + } + if (key === 'fontSize') { + if (typeof value !== 'number' || value < 12 || value > 24) + throw new Error('Invalid fontSize'); + validated.fontSize = value; + } + if (key === 'readingWidth') { + if (!READING_WIDTHS.has(value as AppSettings['readingWidth'])) { + throw new Error('Invalid readingWidth'); + } + validated.readingWidth = value as AppSettings['readingWidth']; + } + if (key === 'lineNumbers') { + if (typeof value !== 'boolean') throw new Error('Invalid lineNumbers'); + validated.lineNumbers = value; + } + if (key === 'customCss') { + if (typeof value !== 'string') throw new Error('Invalid customCss'); + validated.customCss = value; + } + if (key === 'zoom') { + if (typeof value !== 'number' || value < 50 || value > 200) throw new Error('Invalid zoom'); + validated.zoom = value; + } + if (key === 'recentFilesLimit') { + if (typeof value !== 'number' || value < 1 || value > 50) { + throw new Error('Invalid recentFilesLimit'); + } + validated.recentFilesLimit = value; + } + if (key === 'showHiddenFiles') { + if (typeof value !== 'boolean') throw new Error('Invalid showHiddenFiles'); + validated.showHiddenFiles = value; + } + } + + return validated; +} + +/* writes settings to disk without risking file corruption*/ +export async function writeSettingsAtomically( + settingsPath: string, + nextSettings: AppSettings +): Promise { + const dir = path.dirname(settingsPath); + const tempPath = `${settingsPath}.tmp`; + + try { + await mkdir(dir, { recursive: true }); + + const tempFile = await open(tempPath, 'w'); + try { + const data = JSON.stringify(nextSettings, null, 2); + await tempFile.writeFile(data, 'utf-8'); + await tempFile.sync(); + } finally { + await tempFile.close(); + } + await rename(tempPath, settingsPath); + } catch (error) { + await unlink(tempPath).catch(() => {}); + throw error; + } +} + +/* Runs an asynchronous operation sequentially to prevent concurrent execution*/ +let mutex: Promise = Promise.resolve(); + +export async function runExclusive(operation: () => Promise): Promise { + const previous = mutex; + let release!: () => void; + mutex = new Promise((resolve) => { + release = resolve; + }); + + await previous.catch(() => {}); + + try { + return await operation(); + } finally { + release(); + } +} diff --git a/apps/main-processor/src/utils/ipc-validation.ts b/apps/main-processor/src/utils/ipc-validation.ts deleted file mode 100644 index 6c842cd..0000000 --- a/apps/main-processor/src/utils/ipc-validation.ts +++ /dev/null @@ -1,22 +0,0 @@ -import path from 'path'; -import { IpcMainInvokeEvent } from 'electron'; - -// production and dev urls -const allowedOrigin = ['file://', 'http://localhost']; - -//validate the sender -export function validateSender(event: IpcMainInvokeEvent): boolean { - const url = event.senderFrame?.url || ''; - return allowedOrigin.some((origin) => url.startsWith(origin)); -} - -//validate path type -export function validatePath(path: string) { - return typeof path == 'string' && path.trim().length > 0; -} - -//validate file extension -export function validateMarkdownFile(filePath: string): boolean { - const ext = path.extname(filePath).toLowerCase(); - return ext === '.md' || ext === '.markdown'; -} diff --git a/apps/main-processor/tests/ipc.test.ts b/apps/main-processor/tests/ipc.test.ts deleted file mode 100644 index c5cb780..0000000 --- a/apps/main-processor/tests/ipc.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { validateMarkdownFile, validatePath, validateSender } from '../src/utils/ipc-validation'; - -// mocking the url -function mockEvent(url: string) { - return { - senderFrame: { url }, - } as any; -} - -describe('ipc - validation test', () => { - // test 1:- to check blocked urls - it('block unknow urls', () => { - const event = mockEvent('http://hiii.com'); - expect(validateSender(event)).toBe(false); - }); - - //test 2:- to check empty urls - it('return false when url is empty', () => { - const event = mockEvent(''); - expect(validateSender(event)).toBe(false); - }); - - //test 3:- to check correct file path - it('returns true for a valid file path string', () => { - expect(validatePath('C:\\Users\\file.md')).toBe(true); - }); - - //test 4:- to check file extension - it('return true for .md files', () => { - expect(validateMarkdownFile('/docs/readme.md')).toBe(true); - }); -}); diff --git a/apps/main-processor/vitest.config.ts b/apps/main-processor/vitest.config.ts index ad8c5b7..d210e65 100644 --- a/apps/main-processor/vitest.config.ts +++ b/apps/main-processor/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ environment: 'node', globals: true, exclude: ['dist/**', 'node_modules/**'], + setupFiles: ['./setup.ts'], }, resolve: { alias: { diff --git a/apps/preload/src/index.ts b/apps/preload/src/index.ts index c02a343..8969574 100644 --- a/apps/preload/src/index.ts +++ b/apps/preload/src/index.ts @@ -1,8 +1,9 @@ -import { contextBridge, ipcRenderer } from 'electron'; +import { contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron'; import { MarkdownReaderAPI } from '@package/shared-types'; import { IPC_CONSTANTS } from '@package/shared-constants'; import { BRIDGE_NAME } from '@package/shared-constants'; import { MENU_EVENT_LIST } from '@package/shared-constants'; +import { isMenuEvent } from './utils/menu-event-helper'; const apiContract: MarkdownReaderAPI = { readFile: (path) => ipcRenderer.invoke(IPC_CONSTANTS.READ_FILE, path), @@ -15,12 +16,20 @@ const apiContract: MarkdownReaderAPI = { getSettings: () => ipcRenderer.invoke(IPC_CONSTANTS.GET_SETTINGS), saveSettings: (settings) => ipcRenderer.invoke(IPC_CONSTANTS.SAVE_SETTINGS, settings), getAppVersion: () => ipcRenderer.invoke(IPC_CONSTANTS.GET_APP_VERSION), + searchFolder: (path, query) => ipcRenderer.invoke(IPC_CONSTANTS.SEARCH_FOLDER, path, query), watchFile: (path) => ipcRenderer.invoke(IPC_CONSTANTS.WATCH_FILE, path), unWatchFile: (path) => ipcRenderer.invoke(IPC_CONSTANTS.UNWATCH_FILE, path), onFileChanged: (callback: (path: string) => void) => ipcRenderer.on(IPC_CONSTANTS.FILE_CHANGED, (_event, path: string) => callback(path)), removeFileChangedListener: () => ipcRenderer.removeAllListeners(IPC_CONSTANTS.FILE_CHANGED), - onMenuEvent: (event: string, callback: () => void) => ipcRenderer.on(event, () => callback()), + onMenuEvent: (event: string, callback: (payload?: unknown) => void) => { + if (!isMenuEvent(event)) { + throw new Error(`Unsupported menu event: ${event}`); + } + const handler = (_event: IpcRendererEvent, payload?: unknown) => callback(payload); + ipcRenderer.on(event, handler); + return () => ipcRenderer.removeListener(event, handler); + }, removeMenuListeners: () => MENU_EVENT_LIST.forEach((event) => { ipcRenderer.removeAllListeners(event); @@ -33,6 +42,25 @@ const apiContract: MarkdownReaderAPI = { removeOpenFilePathListener: (): void => { ipcRenderer.removeAllListeners(IPC_CONSTANTS.OPEN_FILE_PATH); }, + showSaveDialog: (options) => ipcRenderer.invoke(IPC_CONSTANTS.SHOW_SAVE_DIALOG, options), + + exportHTML: (html, css, outputPath) => + ipcRenderer.invoke(IPC_CONSTANTS.EXPORT_HTML, html, css, outputPath), + + exportPDF: (html, css, outputPath) => + ipcRenderer.invoke(IPC_CONSTANTS.EXPORT_PDF, html, css, outputPath), + exportDOCX: (html, css, outputPath) => + ipcRenderer.invoke(IPC_CONSTANTS.EXPORT_DOCX, html, css, outputPath), + getPathForFile: (file: File) => webUtils.getPathForFile(file), + + onUpdateAvailable: (callback: (version: string) => void) => { + const handler = (_event: IpcRendererEvent, version: string) => callback(version); + ipcRenderer.on(IPC_CONSTANTS.UPDATE_AVAILABLE, handler); + return () => { + ipcRenderer.removeListener(IPC_CONSTANTS.UPDATE_AVAILABLE, handler); + }; + }, + downloadUpdate: () => ipcRenderer.send(IPC_CONSTANTS.DOWNLOAD_UPDATE), }; // bridge between renderer and main diff --git a/apps/preload/src/utils/menu-event-helper.ts b/apps/preload/src/utils/menu-event-helper.ts new file mode 100644 index 0000000..98595eb --- /dev/null +++ b/apps/preload/src/utils/menu-event-helper.ts @@ -0,0 +1,7 @@ +import { MENU_EVENT_LIST } from '@package/shared-constants'; + +export type MenuEventName = (typeof MENU_EVENT_LIST)[number]; + +export function isMenuEvent(event: string): event is MenuEventName { + return MENU_EVENT_LIST.includes(event as MenuEventName); +} diff --git a/apps/renderer/tests/components/Sidebar.test.tsx b/apps/renderer/__tests__/components/Sidebar.test.tsx similarity index 100% rename from apps/renderer/tests/components/Sidebar.test.tsx rename to apps/renderer/__tests__/components/Sidebar.test.tsx diff --git a/apps/renderer/tests/components/StatusBar.test.tsx b/apps/renderer/__tests__/components/StatusBar.test.tsx similarity index 100% rename from apps/renderer/tests/components/StatusBar.test.tsx rename to apps/renderer/__tests__/components/StatusBar.test.tsx diff --git a/apps/renderer/tests/components/TabBar.test.tsx b/apps/renderer/__tests__/components/TabBar.test.tsx similarity index 97% rename from apps/renderer/tests/components/TabBar.test.tsx rename to apps/renderer/__tests__/components/TabBar.test.tsx index 2b10148..d93c128 100644 --- a/apps/renderer/tests/components/TabBar.test.tsx +++ b/apps/renderer/__tests__/components/TabBar.test.tsx @@ -31,7 +31,7 @@ describe('TabBar', () => { it('marks the active tab', () => { render( {}} onClose={() => {}} />); - expect(screen.getByRole('tab', { name: /README/i })).toHaveAttribute('aria-current', 'true'); + expect(screen.getByRole('tab', { name: /README/i })).toHaveAttribute('aria-selected', 'true'); }); it('switches tab when a tab is clicked', () => { @@ -49,7 +49,7 @@ describe('TabBar', () => { render( {}} onClose={onClose} />); - fireEvent.click(screen.getAllByRole('button', { name: /close tab/i })[0]); + fireEvent.click(screen.getAllByRole('button', { name: /close .* tab/i })[0]); expect(onClose).toHaveBeenCalledWith('tab-1'); }); diff --git a/apps/renderer/tests/components/Welcome.test.tsx b/apps/renderer/__tests__/components/Welcome.test.tsx similarity index 100% rename from apps/renderer/tests/components/Welcome.test.tsx rename to apps/renderer/__tests__/components/Welcome.test.tsx diff --git a/apps/renderer/tests/hooks/useCollapsibleToc.test.ts b/apps/renderer/__tests__/hooks/useCollapsibleToc.test.ts similarity index 92% rename from apps/renderer/tests/hooks/useCollapsibleToc.test.ts rename to apps/renderer/__tests__/hooks/useCollapsibleToc.test.ts index 664b22b..5d952ea 100644 --- a/apps/renderer/tests/hooks/useCollapsibleToc.test.ts +++ b/apps/renderer/__tests__/hooks/useCollapsibleToc.test.ts @@ -20,15 +20,19 @@ describe('Table of Contents Hook Tests', () => { test('should hide nested sub items when their parent is collapsed', () => { const { result } = renderHook(() => useCollapsibleToc(sampleDocumentItems)); - expect(result.current.visibleItems.length).toBe(4); + expect(result.current.visibleItems.length).toBe(3); act(() => { result.current.toggleItem('section-1.1'); }); - expect(result.current.visibleItems.length).toBe(3); + expect(result.current.visibleItems.length).toBe(4); const containsSubSection = result.current.visibleItems.some( (item) => item.id === 'subsection-1.1.1' ); - expect(containsSubSection).toBe(false); + expect(containsSubSection).toBe(true); + act(() => { + result.current.toggleItem('section-1.1'); + }); + expect(result.current.visibleItems.length).toBe(3); }); test('should handle completely empty lists without crashing the app', () => { diff --git a/apps/renderer/tests/hooks/useSearch.test.ts b/apps/renderer/__tests__/hooks/useSearch.test.ts similarity index 100% rename from apps/renderer/tests/hooks/useSearch.test.ts rename to apps/renderer/__tests__/hooks/useSearch.test.ts diff --git a/apps/renderer/tests/hooks/useSettings.test.ts b/apps/renderer/__tests__/hooks/useSettings.test.ts similarity index 66% rename from apps/renderer/tests/hooks/useSettings.test.ts rename to apps/renderer/__tests__/hooks/useSettings.test.ts index 2caea62..4144de6 100644 --- a/apps/renderer/tests/hooks/useSettings.test.ts +++ b/apps/renderer/__tests__/hooks/useSettings.test.ts @@ -12,34 +12,45 @@ describe('useSettings font size', () => { expect(result.current.fontSize).toBe(16); }); - it('should increase font size by adding 2px', () => { + it('should increase font size by adding 2px', async () => { const { result } = renderHook(() => useSettings()); - act(() => result.current.increaseFontSize()); + await act(async () => { + result.current.increaseFontSize(); + }); expect(result.current.fontSize).toBe(18); }); - it('should decrease font size by subracting 2px', () => { + it('should decrease font size by subracting 2px', async () => { const { result } = renderHook(() => useSettings()); - act(() => result.current.decreaseFontSize()); + await act(async () => { + result.current.decreaseFontSize(); + }); expect(result.current.fontSize).toBe(14); }); - it('should not exceed 24px', () => { + it('should not exceed 24px', async () => { const { result } = renderHook(() => useSettings()); - act(() => { - for (let i = 0; i < 10; i++) { + + for (let i = 0; i < 10; i++) { + await act(async () => { result.current.increaseFontSize(); - } - }); + }); + } + expect(result.current.fontSize).toBe(24); }); - it('should return recent font size as 16', () => { + it('should return recent font size as 16', async () => { const { result } = renderHook(() => useSettings()); - act(() => { + + await act(async () => { result.current.increaseFontSize(); + }); + + await act(async () => { result.current.resetFontSize(); }); + expect(result.current.fontSize).toBe(16); }); }); diff --git a/apps/renderer/tests/renderer/callout.test.ts b/apps/renderer/__tests__/renderer/callout.test.ts similarity index 100% rename from apps/renderer/tests/renderer/callout.test.ts rename to apps/renderer/__tests__/renderer/callout.test.ts diff --git a/apps/renderer/__tests__/renderer/drag-drop.test.ts b/apps/renderer/__tests__/renderer/drag-drop.test.ts new file mode 100644 index 0000000..89b71c5 --- /dev/null +++ b/apps/renderer/__tests__/renderer/drag-drop.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { extractDroppedMdpath } from '../../src/renderer/drag-drop'; + +const makeTransfer = (files: { name: string; path: string }[]) => { + const filesMock = { + length: files.length, + item: (i: number) => files[i] ?? null, + ...files.reduce( + (acc, file, index) => { + acc[index] = file; + return acc; + }, + {} as Record + ), + }; + + return { + files: filesMock, + } as unknown as DataTransfer; +}; + +describe('extract dropped md file path', () => { + it('it should return path of a dropped .md file', () => { + expect(extractDroppedMdpath(makeTransfer([{ name: 'README.md', path: '/p/README.md' }]))).toBe( + '/p/README.md' + ); + }); + it('should return null for a non markdown file path', () => { + expect(extractDroppedMdpath(makeTransfer([{ name: 'img.png', path: '/img.png' }]))).toBeNull(); + }); + it('it should return null for empty drop', () => { + expect(extractDroppedMdpath(makeTransfer([]))).toBeNull(); + }); + it('it should return first .md file when multiple files dropped', () => { + const dt = makeTransfer([ + { name: 'img.png', path: '/i/png' }, + { name: 'README.md', path: '/r.md' }, + ]); + expect(extractDroppedMdpath(dt)).toBe('/r.md'); + }); +}); diff --git a/apps/renderer/tests/renderer/katex.test.ts b/apps/renderer/__tests__/renderer/katex.test.ts similarity index 100% rename from apps/renderer/tests/renderer/katex.test.ts rename to apps/renderer/__tests__/renderer/katex.test.ts diff --git a/apps/renderer/tests/renderer/markdown.test.ts b/apps/renderer/__tests__/renderer/markdown.test.ts similarity index 79% rename from apps/renderer/tests/renderer/markdown.test.ts rename to apps/renderer/__tests__/renderer/markdown.test.ts index 77ab64c..f711eb0 100644 --- a/apps/renderer/tests/renderer/markdown.test.ts +++ b/apps/renderer/__tests__/renderer/markdown.test.ts @@ -43,4 +43,16 @@ describe('renderMarkdown', () => { const result = await renderMarkdown(''); expect(typeof result).toBe('string'); }); + + it('should not be able to remove iframe tags before sanitization', async () => { + const html = await renderMarkdown(``); + expect(html).toContain(' { + const html = await renderMarkdown(` + + `); + expect(html).toContain('onerror'); + }); }); diff --git a/apps/renderer/tests/renderer/mermaid.test.ts b/apps/renderer/__tests__/renderer/mermaid.test.ts similarity index 100% rename from apps/renderer/tests/renderer/mermaid.test.ts rename to apps/renderer/__tests__/renderer/mermaid.test.ts diff --git a/apps/renderer/__tests__/renderer/sanitize.test.ts b/apps/renderer/__tests__/renderer/sanitize.test.ts new file mode 100644 index 0000000..6c05b08 --- /dev/null +++ b/apps/renderer/__tests__/renderer/sanitize.test.ts @@ -0,0 +1,52 @@ +import DOMPurify from 'dompurify'; +import { describe, expect, it } from 'vitest'; +import { renderMarkdown } from '../../src/renderer/markdown'; + +describe('DOM Purify sanitization ', () => { + it('it should remove iframe tags from rendered markdown html', async () => { + const dirtyHtml = await renderMarkdown(``); + const safeHtml = DOMPurify.sanitize(dirtyHtml); + expect(safeHtml).not.toContain(''); + }); + + it('should remove script tags from rendered markdown html', async () => { + const dirtyHtml = await renderMarkdown(``); + const safeHtml = DOMPurify.sanitize(dirtyHtml); + expect(safeHtml).not.toContain(''); + expect(safeHtml).not.toContain('alert("xss")'); + }); + + it('should remove dangerous inline event handlers from image tags', async () => { + const dirtyHtml = await renderMarkdown(``); + const safeHtml = DOMPurify.sanitize(dirtyHtml); + expect(safeHtml).toContain(' { + const dirtyHtml = await renderMarkdown(`Click me`); + const safeHtml = DOMPurify.sanitize(dirtyHtml); + expect(safeHtml).toContain('Click me'); + expect(safeHtml).not.toContain('javascript:'); + expect(safeHtml).not.toContain("alert('xss')"); + }); + + it('should keep safe markdown content after sanitization', async () => { + const dirtyHtml = await renderMarkdown(` +# Heading +This is a **good** paragraph. +[GitHub](https://github.com) +safe image +`); + const safeHtml = DOMPurify.sanitize(dirtyHtml); + expect(safeHtml).toContain('good'); + expect(safeHtml).toContain('href="https://github.com"'); + expect(safeHtml).toContain('alt="safe image"'); + }); +}); diff --git a/apps/renderer/tests/renderer/sikhi.test.ts b/apps/renderer/__tests__/renderer/sikhi.test.ts similarity index 100% rename from apps/renderer/tests/renderer/sikhi.test.ts rename to apps/renderer/__tests__/renderer/sikhi.test.ts diff --git a/apps/renderer/tests/renderer/toc.test.ts b/apps/renderer/__tests__/renderer/toc.test.ts similarity index 52% rename from apps/renderer/tests/renderer/toc.test.ts rename to apps/renderer/__tests__/renderer/toc.test.ts index b8f7b43..ecc359a 100644 --- a/apps/renderer/tests/renderer/toc.test.ts +++ b/apps/renderer/__tests__/renderer/toc.test.ts @@ -4,47 +4,63 @@ import { extractTOC } from '../../src/renderer/toc'; describe('extract table of contents', () => { //test 1: checks empty array return from no heading it('returns empty array for HTML with no headings', () => { - const html = '

My name is Ashminita

'; - const toc = extractTOC(html); + const markdown = 'My name is Ashminita'; + const toc = extractTOC(markdown); expect(toc).toEqual([]); }); // test 2:- checks single heading extraction it('extracts a single H1 headings', () => { - const html = '

Introduction

text

'; - const toc = extractTOC(html); + const markdown = '# Introduction\n\ntext'; + const toc = extractTOC(markdown); expect(toc).toHaveLength(1); - expect(toc[0]).toEqual({ id: 'intro', text: 'Introduction', level: 1 }); + expect(toc[0]).toEqual({ id: 'introduction', text: 'Introduction', level: 1 }); }); //test 3:-headings without id generate a slug from the text it('generates a id when heading has no id attribute', () => { - const html = '

Getting Started

'; + const html = '## Getting Started'; const toc = extractTOC(html); expect(toc[0]?.id).toBe('getting-started'); }); //test 4:- checks duplicate id it('generates unique ids for duplicate headings', () => { - const html = `

Duplicate

-

Duplicate

`; - const toc = extractTOC(html); + const markdown = `## Duplicate + ## Duplicate`; + const toc = extractTOC(markdown); expect(toc[0].id).toBe('duplicate'); expect(toc[1].id).toBe('duplicate-1'); }); //test 5:-checks inline formating it('handles headings with inline HTML formating', () => { - const html = `

Hello World

`; - const toc = extractTOC(html); + const markdown = `## Hello World`; + const toc = extractTOC(markdown); expect(toc[0].text).toBe('Hello World'); expect(toc[0].id).toBe('hello-world'); }); //test 6:- checks code spans it('handle headings with code spans', () => { - const html = `

Install npm

`; - const toc = extractTOC(html); + const markdown = '## Install `npm`'; + const toc = extractTOC(markdown); expect(toc[0].text).toBe('Install npm'); }); + + it('does not extract headings from fenced code blocks', () => { + const markdown = `# Real Heading + +\`\`\`python +# Not a heading +## Also not a heading +\`\`\``; + const toc = extractTOC(markdown); + expect(toc).toEqual([{ id: 'real-heading', text: 'Real Heading', level: 1 }]); + }); + + it('handles headings with links', () => { + const toc = extractTOC('## Read [the docs](https://example.com)'); + expect(toc[0]).toEqual({ id: 'read-the-docs', text: 'Read the docs', level: 2 }); + }); }); diff --git a/apps/renderer/tests/store/tabStore.test.ts b/apps/renderer/__tests__/store/tabStore.test.ts similarity index 100% rename from apps/renderer/tests/store/tabStore.test.ts rename to apps/renderer/__tests__/store/tabStore.test.ts diff --git a/apps/renderer/index.html b/apps/renderer/index.html index 1c74a50..e35395f 100644 --- a/apps/renderer/index.html +++ b/apps/renderer/index.html @@ -3,11 +3,13 @@ Markdown Reader + + diff --git a/apps/renderer/public/icons/app-icon.svg b/apps/renderer/public/icons/app-icon.svg new file mode 100644 index 0000000..9c48a93 --- /dev/null +++ b/apps/renderer/public/icons/app-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/renderer/setup.ts b/apps/renderer/setup.ts index 9f3f123..3cb5ddf 100644 --- a/apps/renderer/setup.ts +++ b/apps/renderer/setup.ts @@ -1,4 +1,5 @@ import '@testing-library/jest-dom'; +import { vi } from 'vitest'; // mock svg measurments for jsdom environment for mermaid testing if (typeof window !== 'undefined' && window.SVGElement) { @@ -20,3 +21,20 @@ if (typeof window !== 'undefined' && window.SVGElement) { proto.getComputedTextLength = () => 100; } } + +let mockSettings = { + fontSize: 16, + readingWidth: 'default', + customCss: '', +}; + +Object.defineProperty(window, 'api', { + value: { + getSettings: vi.fn(async () => mockSettings), + saveSettings: vi.fn(async (partial) => { + mockSettings = { ...mockSettings, ...partial }; + return mockSettings; + }), + }, + writable: true, +}); diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index c32368a..ce554f6 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef, useCallback } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { useFile } from './hooks/useFile'; import { Welcome } from './components/Welcome'; import { Reader } from './components/Reader'; @@ -6,123 +6,79 @@ import { Loading } from './components/Loading'; import { Error } from './components/Error'; import { useToc } from './hooks/useTOC'; import { Sidebar } from './components/Sidebar'; -import { useWatcher } from './hooks/useWatcher'; -import { saveScrollPos, getScrollPos } from './renderer/scroll'; import { Toast } from './components/Toast'; import { useTheme } from './hooks/useTheme'; import { useSearch } from './hooks/useSearch'; import { SearchBar } from './components/SearchBar'; import { useSettings } from './hooks/useSettings'; import { StatusBar } from './components/StatusBar'; -import { FileType } from '@package/shared-types'; import { FileBrowser } from './components/FileBrowser'; import { TabBar } from './components/TabBar'; import { useTabStore } from './hooks/useTabStore'; -import { extractTOC } from './renderer/toc'; import { Icons } from './utils/constants/icon-contants'; import { useShortcuts } from './hooks/useShortcuts'; import { useMenuEvents } from './hooks/useMenuEvents'; +import { UpdateBanner } from './components/UpdateBanner'; +import { useExport } from './hooks/useExport'; +import { useDragDrop } from './hooks/useDragDrop'; +import { useTabNavigation } from './hooks/useTabNavigation'; +import { DragDrop } from './components/DragDrop'; +import { useLayout } from './hooks/useLayout'; +import { useFileActions } from './hooks/useFileActions'; +import { useOpenFilePath } from './hooks/useOpenFilePath'; +import { useFilePersistence } from './hooks/useFilePersistence'; +import { ReaderToolbar } from './components/ReaderToolbar'; +import { useFolderSearch } from './hooks/useFolderSearch'; +import { SettingsPanel } from './components/SettingsPanel'; +import { ErrorBoundary } from './components/ErrorBoundary'; export default function App() { const { error, isLoading, openFile, toc,recentFiles,loadFile } =useFile(); const { state, dispatch } = useTabStore(); const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId) ?? null; - const { theme, toggleTheme} = useTheme(); - const {activeId,scrollToHeading}=useToc(toc); - const {increaseFontSize,decreaseFontSize,resetFontSize,fontSize}=useSettings(); + const activeToc=activeTab?.toc?? toc; + const { theme, toggleTheme,setTheme} = useTheme(); + const {activeId,scrollToHeading}=useToc(activeToc); + const {settings,increaseFontSize,decreaseFontSize,resetFontSize,fontSize,updateSettings}=useSettings(); const {query,matchCount,currentMatch,isSearchOpen,openSearch,closeSearch,setQuery,goToNextMatch,goToPrevMatch,getHiglightedHtml} = useSearch(activeTab?.html ?? ''); - const [sidebarOpen, setSidebarOpen] = useState(true); const [showToast, setShowToast] = useState(false); - const [folderTree, setFolderTree] = useState(null); - const [fileBrowserOpen, setFileBrowserOpen] = useState(false); - const [focusMode, setFocusMode] = useState(false); const contentRef=useRef(null); - const debounceTimer=useRef(undefined); - const scrollTimer=useRef(undefined); - - const openFolder = useCallback(async () => { - const folderPath = await window.api.openFolderDialog(); - if (!folderPath) return; - const tree = await window.api.readFolder(folderPath); - setFolderTree(tree); - setFileBrowserOpen(true); - }, []); - - const loadFileInTab = useCallback( - async (path: string) => { - const result = await loadFile(path); - if (!result) return; - dispatch({ - type: 'OPEN_TAB', - payload: { - filePath: result.filePath, - html: result.html, - }, - }); - }, - [loadFile, dispatch] - ); - const openFileDialog = useCallback(() => { - void window.api.openFileDialog().then((chosenPath) => { - if (chosenPath) { - void loadFileInTab(chosenPath); - } - }); -}, [loadFileInTab]); - -const toggleSidebar = useCallback(() => { - setSidebarOpen((prev) => !prev); -}, []); - -const toggleFileBrowser = useCallback(() => { - setFileBrowserOpen((prev) => !prev); -}, []); - -const toggleFocusMode = useCallback(() => { - setFocusMode((prev) => !prev); -}, []); - -const goToNextTab = useCallback(() => { - const idx = state.tabs.findIndex((tab) => tab.id === state.activeTabId); - const next = state.tabs[idx + 1] || state.tabs[0]; - - if (next) { - dispatch({ type: 'SWITCH_TAB', payload: { tabId: next.id } }); - } -}, [state.tabs, state.activeTabId, dispatch]); - -const goToPreviousTab = useCallback(() => { - const idx = state.tabs.findIndex((tab) => tab.id === state.activeTabId); - const prev = state.tabs[idx - 1] || state.tabs[state.tabs.length - 1]; - - if (prev) { - dispatch({ type: 'SWITCH_TAB', payload: { tabId: prev.id } }); - } -}, [state.tabs, state.activeTabId, dispatch]); - -const closeActiveTab = useCallback(() => { - if (!state.activeTabId) return; - - dispatch({ - type: 'CLOSE_TAB', - payload: { tabId: state.activeTabId }, - }); -}, [state.activeTabId, dispatch]); + const {exportHtml,exportPdf,exportDocx}=useExport(activeTab); + const {goToNextTab,goToPreviousTab,closeActiveTab}=useTabNavigation(state.tabs,state.activeTabId,dispatch); + const {sidebarOpen,setSidebarOpen,fileBrowserOpen,setFileBrowserOpen,focusMode,toggleFocusMode,toggleSidebar,toggleFileBrowser}=useLayout(); + const {folderTree,folderPath,openFolder,loadFileInTab,openFileDialog}=useFileActions({loadFile,dispatch}); + const {isDraggingFile,handleDragEnter,handleDragOver,handleDragLeave,handleDrop}=useDragDrop(loadFileInTab); + useOpenFilePath(loadFileInTab); + const {scroll}=useFilePersistence({activeTab,loadFile,dispatch,contentRef,setShowToast}); + const {isFolderSearchOpen,folderQuery,folderResults,isSearchingFolder,openFolderSearch,closeFolderSearch,searchFolder}=useFolderSearch(folderPath) + const [settingsOpen, setSettingsOpen] = useState(false); + const [appVersion, setAppVersion] = useState(''); + + useEffect(()=>{ + if(!window.api?.getAppVersion) return; + void window.api.getAppVersion().then(setAppVersion).catch(()=>{}); + },[]) + useEffect(()=>{ + if(folderTree){ + setFileBrowserOpen(true); + } + },[folderTree,setFileBrowserOpen]); - useEffect(() => { - window.api.onOpenFilePath((path) => { - void loadFileInTab(path); - }); - return () => { - window.api.removeOpenFilePathListener(); - }; - }, [loadFileInTab]); + useEffect(()=>{ + if(!query || ! isSearchOpen) return; + requestAnimationFrame(()=>{ + const marks=contentRef.current?.querySelectorAll('mark.search-match'); + const target = marks?.[Math.max(0, currentMatch - 1)]; + target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }) + },[currentMatch, isSearchOpen, query, activeTab?.html]) useMenuEvents({ onOpenFile: openFileDialog, onOpenFolder: openFolder, onSearchDocument: openSearch, + onSearchFolder: openFolderSearch, onToggleToc: toggleSidebar, onToggleBrowser: toggleFileBrowser, onFocusMode: toggleFocusMode, @@ -133,6 +89,11 @@ const closeActiveTab = useCallback(() => { onNextTab: goToNextTab, onPreviousTab: goToPreviousTab, onCloseTab: closeActiveTab, + onExportHtml:exportHtml, + onExportPdf:exportPdf, + onExportDocx:exportDocx, + onOpenSettings:()=>setSettingsOpen(true), + onSetTheme:setTheme }); useShortcuts({ @@ -141,71 +102,23 @@ useShortcuts({ onToggleFocusMode: toggleFocusMode, onToggleTheme: toggleTheme, onOpenSearch: openSearch, + onOpenFolderSearch: openFolderSearch, onCloseSearch: closeSearch, onZoomIn: increaseFontSize, onZoomOut: decreaseFontSize, onZoomReset: resetFontSize, onToggleSidebar: toggleSidebar, onToggleFileBrowser: toggleFileBrowser, + onOpenSettings:()=>setSettingsOpen(true) }); - const handleFileChange = useCallback(() => { - if(!activeTab) return; - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); - } - debounceTimer.current = window.setTimeout(async () => { - const currentScroll = contentRef.current?.scrollTop ?? 0; - const result =await loadFile(activeTab.filePath); - if(!result) return; - dispatch({ - type: 'UPDATE_TAB_STATE', - payload: { - tabId: activeTab.id, - html: result.html, - }, - }); - requestAnimationFrame(() => { - if (contentRef.current) { - contentRef.current.scrollTop = currentScroll; - } - }); - setShowToast(true); - }, 150); - }, [activeTab,loadFile,dispatch]); - const scroll = () => { - if (!activeTab || !contentRef.current) return; - - if (scrollTimer.current) { - clearTimeout(scrollTimer.current); - } - - scrollTimer.current = window.setTimeout(() => { - saveScrollPos(activeTab.filePath, contentRef.current!.scrollTop); - - dispatch({ - type: 'UPDATE_TAB_STATE', - payload: { - tabId: activeTab.id, - scrollTop: contentRef.current?.scrollTop ?? 0, - }, - }); - }, 100); -}; - useWatcher(activeTab?.filePath ??'', handleFileChange); - useEffect(() => { - if (!activeTab || !contentRef.current) return; - - requestAnimationFrame(() => { - if (contentRef.current) { - contentRef.current.scrollTop = - activeTab.scrollTop ?? getScrollPos(activeTab.filePath); - } - }); -}, [activeTab?.id, activeTab?.html]); + return ( <> -
+
+ {isDraggingFile &&( + + )} {isLoading && } {isSearchOpen && ( )} + {isFolderSearchOpen && ( + {}} + onPrev={() => {}} + onClose={closeFolderSearch} + folderResults={folderResults} + isSearchingFolder={isSearchingFolder} + hasFolder={Boolean(folderPath)} + onOpenFolderResult={(result) => { + void loadFileInTab(result.filePath).then(() => { + openSearch(); + setQuery(folderQuery); + closeFolderSearch(); + }).catch(() => { + setShowToast(true); + }); + }} + + /> + )} {!focusMode && ( dispatch({ type: 'SWITCH_TAB', payload: { tabId: id } })} onClose={(id) => dispatch({ type: 'CLOSE_TAB', payload: { tabId: id } })} + plusOpen={openFileDialog} /> )} + {error && } @@ -257,7 +197,7 @@ useShortcuts({ )} {!focusMode && ( )} {!focusMode && ( -
- - - - - - -
- - -
+ )}
- + + +
)} @@ -318,6 +223,7 @@ useShortcuts({ {!focusMode && ( )} + setSettingsOpen(false)} onChange={(partial)=>void updateSettings(partial)} appVersion={appVersion}/>
)} diff --git a/apps/renderer/src/components/DragDrop.tsx b/apps/renderer/src/components/DragDrop.tsx new file mode 100644 index 0000000..4e36766 --- /dev/null +++ b/apps/renderer/src/components/DragDrop.tsx @@ -0,0 +1,13 @@ + +/*drag drop component to get displayed on Screen , +when a file is dragged and dropped on the application */ + +export function DragDrop() { + return ( +
+
+ Drop Markdown file to open +
+
+ ); +} \ No newline at end of file diff --git a/apps/renderer/src/components/Error.tsx b/apps/renderer/src/components/Error.tsx index 180d108..e050b3f 100644 --- a/apps/renderer/src/components/Error.tsx +++ b/apps/renderer/src/components/Error.tsx @@ -3,9 +3,10 @@ import { ErrorProps } from "../types/component-types" //error message display function export function Error({message,onRetry}:ErrorProps){ return ( -
+

Error: {message}

+
+ ); + } + + return this.props.children; + } +} diff --git a/apps/renderer/src/components/FileBrowser.tsx b/apps/renderer/src/components/FileBrowser.tsx index cdd83ca..0153a55 100644 --- a/apps/renderer/src/components/FileBrowser.tsx +++ b/apps/renderer/src/components/FileBrowser.tsx @@ -1,6 +1,5 @@ import { FileBrowserProps } from '../types/component-types'; import { FileTree } from './FileTree'; -import { Icons } from '../utils/constants/icon-contants'; export function FileBrowser({ tree, @@ -12,11 +11,11 @@ export function FileBrowser({ return (