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..ad3cb95 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -20,7 +20,7 @@ jobs: node-version: 22 - name: Setup pnpm - uses: pnpm/action-setup@v3 + uses: pnpm/action-setup@v4 with: version: 10 @@ -40,4 +40,37 @@ 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 + with: + version: 10 + + - 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/.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/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 similarity index 100% rename from apps/main-processor/tests/docx.test.ts rename to apps/main-processor/__tests__/docx.test.ts diff --git a/apps/main-processor/tests/export.test.ts b/apps/main-processor/__tests__/export.test.ts similarity index 71% rename from apps/main-processor/tests/export.test.ts rename to apps/main-processor/__tests__/export.test.ts index 568fbf1..6c5ebac 100644 --- a/apps/main-processor/tests/export.test.ts +++ b/apps/main-processor/__tests__/export.test.ts @@ -1,7 +1,11 @@ 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', () => { @@ -50,3 +54,17 @@ describe('get image of mime type', () => { expect(getImage('image.webp')).toBe('image/webp'); }); }); + +describe('inline images for HTML export', () => { + it('keeps local images as base 64 data URIs', async () => { + const dir = await mkdtemp(join(tmpdir(), 'markdown-reader-export-')); + try { + const imagePath = join(dir, 'image.png'); + await writeFile(imagePath, Buffer.from([137, 80, 78, 71])); + const html = await inlineImages(``); + expect(html).toContain('src="data:image/png;base64,'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/main-processor/tests/file.test.ts b/apps/main-processor/__tests__/file.test.ts similarity index 72% rename from apps/main-processor/tests/file.test.ts rename to apps/main-processor/__tests__/file.test.ts index f4a8498..9ec3934 100644 --- a/apps/main-processor/tests/file.test.ts +++ b/apps/main-processor/__tests__/file.test.ts @@ -2,6 +2,7 @@ import { vi, describe, expect, it, beforeAll, afterAll } from 'vitest'; import { writeFileSync, unlinkSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { getWatcherDiagnostics } from '../src/file'; import { readFile, watchFile, unWatchFile } from '../src/file'; @@ -47,6 +48,7 @@ describe('File watcher', () => { writeFileSync(TEST_FILE, 'change after unwatch'); await new Promise((r) => setTimeout(r, 200)); expect(cb).not.toHaveBeenCalled(); + expect(getWatcherDiagnostics().debounceTimers).toBe(0); }); it('should protect from double call', async () => { @@ -61,4 +63,23 @@ describe('File watcher', () => { it('should not crash if I unwatch a file which is not watched', async () => { await expect(unWatchFile('fake-file.txt')).resolves.toBeUndefined(); }); + + it('should clean debounce timer references after unwatching', async () => { + const cb = vi.fn(); + await watchFile(TEST_FILE, cb); + writeFileSync(TEST_FILE, 'change with pending debounce'); + await new Promise((r) => setTimeout(r, 25)); + await unWatchFile(TEST_FILE); + expect(getWatcherDiagnostics().debounceTimers).toBe(0); + }); + + it('does not call callback for a file unwatched during debounce', async () => { + const cb = vi.fn(); + await watchFile(TEST_FILE, cb); + writeFileSync(TEST_FILE, 'change before immediate unwatch'); + await new Promise((r) => setTimeout(r, 25)); + await unWatchFile(TEST_FILE); + await new Promise((r) => setTimeout(r, 200)); + expect(cb).not.toHaveBeenCalled(); + }); }); diff --git a/apps/main-processor/__tests__/folder-search.test.ts b/apps/main-processor/__tests__/folder-search.test.ts new file mode 100644 index 0000000..3b9d644 --- /dev/null +++ b/apps/main-processor/__tests__/folder-search.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { searchFolder } from '../src/folder-search'; + +const DIR = join(tmpdir(), 'md-search-test'); + +beforeAll(() => { + mkdirSync(DIR, { recursive: true }); + writeFileSync(join(DIR, 'README.md'), '# Project\nThis project uses React.\nReact is great.'); + writeFileSync(join(DIR, 'CHANGELOG.md'), '# Changes\nVersion 1.0 released.'); + writeFileSync(join(DIR, 'notes.txt'), 'not a markdown file'); +}); +afterAll(() => { + rmSync(DIR, { recursive: true, force: true }); +}); + +describe('search folder', () => { + it('should find matches in .md files and returns results', async () => { + const results = await searchFolder(DIR, 'React'); + expect(results.length).toBeGreaterThan(0); + expect(results.some((r) => r.filePath.endsWith('README.md'))).toBe(true); + }); + + it('does not search non .md files', async () => { + const results = await searchFolder(DIR, 'not a markdown'); + expect(results.every((r) => r.filePath.endsWith('.md'))).toBe(true); + }); + + it('should return empty array when query matches nothing', async () => { + const results = await searchFolder(DIR, 'xyznotfound'); + expect(results).toHaveLength(0); + }); + + it('has search case insensitive', async () => { + const lower = await searchFolder(DIR, 'react'); + const upper = await searchFolder(DIR, 'REACT'); + expect(lower.length).toBe(upper.length); + }); + + it('returns an empty array if the directory does not exist', async () => { + const nonExistentDir = join(DIR, 'does-not-exist-folder'); + + const results = await searchFolder(nonExistentDir, 'React'); + expect(results).toEqual([]); + }); +}); diff --git a/apps/main-processor/__tests__/folder.test.ts b/apps/main-processor/__tests__/folder.test.ts new file mode 100644 index 0000000..6ae5277 --- /dev/null +++ b/apps/main-processor/__tests__/folder.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { getFolder } from '../src/folder'; + +describe('folder reader testing', async () => { + it('should enforce the recursion depth', async () => { + const root = await mkdtemp(join(tmpdir(), 'markdown-reader-folder-')); + let current = root; + for (let index = 0; index < 12; index += 1) { + current = join(current, `level-${index}`); + await mkdir(current); + await writeFile(join(current, `file-${index}.md`), '# test', 'utf-8'); + } + + const tree = await getFolder(root, 2); + const first = tree.children?.find((child) => child.isDir); + const second = first?.children?.find((child) => child.isDir); + expect(second?.children).toEqual([]); + }); +}); diff --git a/apps/main-processor/tests/ipc.test.ts b/apps/main-processor/__tests__/ipc.test.ts similarity index 100% rename from apps/main-processor/tests/ipc.test.ts rename to apps/main-processor/__tests__/ipc.test.ts diff --git a/apps/main-processor/tests/menu.test.ts b/apps/main-processor/__tests__/menu.test.ts similarity index 100% rename from apps/main-processor/tests/menu.test.ts rename to apps/main-processor/__tests__/menu.test.ts diff --git a/apps/main-processor/tests/recent.test.ts b/apps/main-processor/__tests__/recent.test.ts similarity index 92% rename from apps/main-processor/tests/recent.test.ts rename to apps/main-processor/__tests__/recent.test.ts index d91170c..775d097 100644 --- a/apps/main-processor/tests/recent.test.ts +++ b/apps/main-processor/__tests__/recent.test.ts @@ -1,9 +1,4 @@ -import { vi, describe, it, expect } from 'vitest'; -vi.mock('electron', () => ({ - app: { - isPackaged: false, - }, -})); +import { describe, it, expect } from 'vitest'; import { RecentFile } from '@package/shared-types'; import { addToRecentList } from '../src/recent/addToRecentList'; import { getUniqueRecentFile } from '../src/recent/getUniqueRecentFile'; diff --git a/apps/main-processor/__tests__/settings.test.ts b/apps/main-processor/__tests__/settings.test.ts new file mode 100644 index 0000000..149afa4 --- /dev/null +++ b/apps/main-processor/__tests__/settings.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { validateSettings } from '../src/utils/helper/setting-helper'; +import { READING_WIDTHS } from '../src/utils/constants/setting-constants'; +import { THEMES } from '@package/shared-constants'; +describe('settings validation', () => { + it('accepts valid settings', () => { + expect(validateSettings({ fontSize: 18 })).toEqual({ fontSize: 18 }); + }); + + it('rejects unknown keys', () => { + expect(() => validateSettings({ unexpected: true } as never)).toThrow('Unknown settings key'); + }); + + it('rejects invalid values', () => { + expect(() => validateSettings({ fontSize: 100 })).toThrow('Invalid fontSize'); + }); + + it('validates theme values', () => { + expect(validateSettings({ theme: THEMES[0] })).toEqual({ theme: THEMES[0] }); + expect(() => validateSettings({ theme: 'unknown-theme' as never })).toThrow('Invalid theme'); + }); + + it('validates readingWidth values', () => { + expect(READING_WIDTHS.has('default')).toBe(true); + expect(validateSettings({ readingWidth: 'default' })).toEqual({ readingWidth: 'default' }); + expect(() => validateSettings({ readingWidth: 'full' as never })).toThrow( + 'Invalid readingWidth' + ); + }); + + it('validates boolean settings', () => { + expect(validateSettings({ lineNumbers: true })).toEqual({ lineNumbers: true }); + expect(validateSettings({ showHiddenFiles: false })).toEqual({ showHiddenFiles: false }); + expect(() => validateSettings({ lineNumbers: 'yes' as never })).toThrow('Invalid lineNumbers'); + expect(() => validateSettings({ showHiddenFiles: 'no' as never })).toThrow( + 'Invalid showHiddenFiles' + ); + }); + + it('validates customCss values', () => { + expect(validateSettings({ customCss: '.markdown-body { color: red; }' })).toEqual({ + customCss: '.markdown-body { color: red; }', + }); + expect(() => validateSettings({ customCss: 42 as never })).toThrow('Invalid customCss'); + }); + + it('validates zoom range', () => { + expect(validateSettings({ zoom: 50 })).toEqual({ zoom: 50 }); + expect(validateSettings({ zoom: 200 })).toEqual({ zoom: 200 }); + expect(() => validateSettings({ zoom: 49 })).toThrow('Invalid zoom'); + expect(() => validateSettings({ zoom: 201 })).toThrow('Invalid zoom'); + }); + + it('validates recentFilesLimit range', () => { + expect(validateSettings({ recentFilesLimit: 1 })).toEqual({ recentFilesLimit: 1 }); + expect(validateSettings({ recentFilesLimit: 50 })).toEqual({ recentFilesLimit: 50 }); + expect(() => validateSettings({ recentFilesLimit: 0 })).toThrow('Invalid recentFilesLimit'); + expect(() => validateSettings({ recentFilesLimit: 51 })).toThrow('Invalid recentFilesLimit'); + }); +}); diff --git a/apps/main-processor/setup.ts b/apps/main-processor/setup.ts new file mode 100644 index 0000000..00953d2 --- /dev/null +++ b/apps/main-processor/setup.ts @@ -0,0 +1,8 @@ +import { vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: vi.fn(), + }, +})); diff --git a/apps/main-processor/src/export/inlineImage.ts b/apps/main-processor/src/export/inlineImage.ts index a178504..be5d112 100644 --- a/apps/main-processor/src/export/inlineImage.ts +++ b/apps/main-processor/src/export/inlineImage.ts @@ -9,7 +9,7 @@ export async function inlineImages(html: string): Promise { for (const match of matches) { const fullMatch = match[0]; - const src = match[1]; + const src = match[2]; if (!src) continue; diff --git a/apps/main-processor/src/file.ts b/apps/main-processor/src/file.ts index 1778dbb..6f2670d 100644 --- a/apps/main-processor/src/file.ts +++ b/apps/main-processor/src/file.ts @@ -1,5 +1,6 @@ import { readFile as fsReadFile } from 'node:fs/promises'; import chokidar, { type FSWatcher } from 'chokidar'; +import { WatchFileOptions } from './types/watch-file-types'; //file read logic export async function readFile(filePath: string): Promise { @@ -12,11 +13,17 @@ export async function readFile(filePath: string): Promise { } const currentWatchers = new Map(); +const debounceTimers = new Map(); //file watching logic -export async function watchFile(filePath: string, onChange: () => void): Promise { +export async function watchFile( + filePath: string, + options: WatchFileOptions | (() => void) +): Promise { + const { onChange, onDeleted, onError } = + typeof options === 'function' ? { onChange: options } : options; + if (currentWatchers.has(filePath)) { - await currentWatchers.get(filePath)!.close(); - currentWatchers.delete(filePath); + await unWatchFile(filePath); } const watcher = chokidar.watch(filePath, { @@ -30,13 +37,47 @@ export async function watchFile(filePath: string, onChange: () => void): Promise await new Promise((resolve) => { watcher.on('ready', resolve); }); - watcher.on('change', onChange); + watcher.on('change', () => { + const existingTimer = debounceTimers.get(filePath); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + if (!currentWatchers.has(filePath)) { + debounceTimers.delete(filePath); + return; + } + debounceTimers.delete(filePath); + onChange(); + }, 100); + debounceTimers.set(filePath, timer); + }); + watcher.on('unlink', () => { + void unWatchFile(filePath).then(() => onDeleted?.()); + }); + watcher.on('error', (error) => { + const watcherError = error instanceof Error ? error : new Error(String(error)); + void unWatchFile(filePath).then(() => onError?.(watcherError)); + }); currentWatchers.set(filePath, watcher); } //unwatch file export async function unWatchFile(filePath: string): Promise { + const timer = debounceTimers.get(filePath); + if (timer) { + clearTimeout(timer); + debounceTimers.delete(filePath); + } if (!currentWatchers.has(filePath)) return; await currentWatchers.get(filePath)!.close(); currentWatchers.delete(filePath); } + +export function getWatcherDiagnostics(): { watchers: number; debounceTimers: number } { + return { + watchers: currentWatchers.size, + debounceTimers: debounceTimers.size, + }; +} diff --git a/apps/main-processor/src/folder-search.ts b/apps/main-processor/src/folder-search.ts new file mode 100644 index 0000000..b691183 --- /dev/null +++ b/apps/main-processor/src/folder-search.ts @@ -0,0 +1,45 @@ +import { readFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { FolderSearchResult } from '@package/shared-types'; +import { MAX_RESULTS } from './utils/constants/folder-constants'; +import { collectMarkdownFiles } from './utils/helper/folder-search-helper'; + +/*This searches Markdown files in a folder for text matches and returns formatted results*/ +export async function searchFolder( + folderPath: string, + query: string +): Promise { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return []; + + const results: FolderSearchResult[] = []; + const files = await collectMarkdownFiles(folderPath); + + for (const filePath of files) { + let content: string; + try { + content = await readFile(filePath, 'utf-8'); + } catch (error) { + const code = (error as { code?: string }).code; + if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') continue; + throw error; + } + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const lineText = lines[index] ?? ''; + if (!lineText.toLowerCase().includes(normalizedQuery)) continue; + + results.push({ + filePath, + fileName: basename(filePath), + line: index + 1, + column: lineText.toLowerCase().indexOf(normalizedQuery) + 1, + preview: lineText.trim().slice(0, 240), + }); + + if (results.length >= MAX_RESULTS) return results; + } + } + + return results; +} diff --git a/apps/main-processor/src/folder.ts b/apps/main-processor/src/folder.ts index a2d5f09..36bf038 100644 --- a/apps/main-processor/src/folder.ts +++ b/apps/main-processor/src/folder.ts @@ -1,19 +1,42 @@ -import { readdir } from 'node:fs/promises'; +import { readdir, realpath } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { FileType } from '@package/shared-types'; import { isMarkdownFile } from './utils/helper/path-helper'; +import { MAX_FOLDER_DEPTH } from './utils/constants/path-constants'; -export async function getFolder(folderPath: string): Promise { - const entries = await readdir(folderPath, { withFileTypes: true }); +export async function getFolder( + folderPath: string, + maxDepth = MAX_FOLDER_DEPTH, + currentDepth = 0, + seenPaths = new Set() +): Promise { + let realFolderPath: string; + try { + realFolderPath = await realpath(folderPath); + } catch { + realFolderPath = folderPath; + } + if (currentDepth >= maxDepth || seenPaths.has(realFolderPath)) { + return { + name: basename(folderPath), + path: folderPath, + isDir: true, + children: [], + }; + } + seenPaths.add(realFolderPath); + const entries = (await readdir(folderPath, { withFileTypes: true })).sort((a, b) => + a.name.localeCompare(b.name) + ); const children: FileType[] = []; for (const entry of entries) { if (entry.name.startsWith('.')) continue; - + if (entry.isSymbolicLink()) continue; const fullPath = join(folderPath, entry.name); if (entry.isDirectory()) { - children.push(await getFolder(fullPath)); + children.push(await getFolder(fullPath, maxDepth, currentDepth + 1, seenPaths)); continue; } diff --git a/apps/main-processor/src/index.ts b/apps/main-processor/src/index.ts index e330de4..422f9c3 100644 --- a/apps/main-processor/src/index.ts +++ b/apps/main-processor/src/index.ts @@ -6,16 +6,18 @@ import { PATHS } from './utils/constants/path-constants'; import { registerMenu } from './register-menu'; import { parseFilePathFromArgv } from './cli'; import { setupAutoUpdater } from './updater'; +import { resolveMarkdownFilePath } from './utils/helper/ipc-path-resolver'; let mainWindow: BrowserWindow | null = null; let pendingFilePath: string | null = null; -function sendFilePathToRenderer(filePath: string): void { +async function sendFilePathToRenderer(filePath: string): Promise { + const safeFilePath = await resolveMarkdownFilePath(filePath); if (!mainWindow) { - pendingFilePath = filePath; + pendingFilePath = safeFilePath; return; } - mainWindow.webContents.send('open-file-path', filePath); + mainWindow.webContents.send('open-file-path', safeFilePath); } // register all IPC before window is created @@ -71,7 +73,9 @@ function createWindow(): void { app.on('open-file', (event, filePath) => { event.preventDefault(); - sendFilePathToRenderer(filePath); + void sendFilePathToRenderer(filePath).catch((error) => { + console.error('Failed to open file from Os:-', error); + }); }); const hasSingleInstanceLock = app.requestSingleInstanceLock(); if (!hasSingleInstanceLock) { diff --git a/apps/main-processor/src/ipc.ts b/apps/main-processor/src/ipc.ts index 2ea84b7..6410d76 100644 --- a/apps/main-processor/src/ipc.ts +++ b/apps/main-processor/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, dialog } from 'electron'; +import { app, ipcMain, dialog } from 'electron'; import { readFile, unWatchFile, watchFile } from './file'; import { getFolder } from './folder'; import { validatePath, validateSender, allowedFolderRoots } from './utils/constants/ipc-validation'; @@ -13,6 +13,10 @@ import { resolveDirectoryPath, resolveWatchedMarkdownPath, } from './utils/helper/ipc-path-resolver'; +import { searchFolder } from './folder-search'; +import { AppSettings } from '@package/shared-types'; +import { getSettings } from './settings/get-settings'; +import { saveSettings } from './settings/save-settings'; //registers all IPC handlers for main process export function registerIPCHandlers(): void { @@ -113,6 +117,27 @@ export function registerIPCHandlers(): void { return await getFolder(safeFolderPath); }); + ipcMain.handle(IPC_CONSTANTS.GET_SETTINGS, async (event) => { + if (!validateSender(event)) { + throw new Error('Untrusted sender'); + } + return await getSettings(); + }); + + ipcMain.handle(IPC_CONSTANTS.SAVE_SETTINGS, async (event, settings: Partial) => { + if (!validateSender(event)) { + throw new Error('Untrusted sender'); + } + return await saveSettings(settings); + }); + + ipcMain.handle(IPC_CONSTANTS.GET_APP_VERSION, async (event) => { + if (!validateSender(event)) { + throw new Error('Untrusted sender'); + } + return app.getVersion(); + }); + ipcMain.handle( IPC_CONSTANTS.EXPORT_HTML, async (event, html: string, css: string, outPath: string) => { @@ -169,4 +194,20 @@ export function registerIPCHandlers(): void { await exportDOCX(html, css, outPath); } ); + + ipcMain.handle(IPC_CONSTANTS.SEARCH_FOLDER, async (event, folderPath: string, query: string) => { + if (!validateSender(event)) { + throw new Error('Untrusted sender'); + } + + const safeFolderPath = await resolveDirectoryPath(folderPath); + allowedFolderRoots.add(safeFolderPath); + const isAllowed = Array.from(allowedFolderRoots).some( + (root) => safeFolderPath === root || safeFolderPath.startsWith(`${root}/`) + ); + if (!isAllowed) { + throw new Error('Folder path is not authorized'); + } + return await searchFolder(safeFolderPath, query); + }); } diff --git a/apps/main-processor/src/menu.ts b/apps/main-processor/src/menu.ts index f1bc096..061453f 100644 --- a/apps/main-processor/src/menu.ts +++ b/apps/main-processor/src/menu.ts @@ -1,5 +1,5 @@ import type { MenuItemConstructorOptions } from 'electron'; -import { MENU_EVENTS, MENU_LABELS, SHORTCUTS } from '@package/shared-constants'; +import { MENU_EVENTS, MENU_LABELS, SHORTCUTS, THEMES } from '@package/shared-constants'; import { createMenuSender } from './utils/helper/menu-helper'; export function buildMenuTemplate(): MenuItemConstructorOptions[] { @@ -60,12 +60,24 @@ export function buildMenuTemplate(): MenuItemConstructorOptions[] { accelerator: SHORTCUTS.FOCUS_MODE, click: send(MENU_EVENTS.FOCUS_MODE), }, + { + label: MENU_LABELS.SETTINGS, + click: send(MENU_EVENTS.OPEN_SETTINGS), + }, { type: 'separator' }, { label: MENU_LABELS.CYCLE_THEME, accelerator: SHORTCUTS.CYCLE_THEME, click: send(MENU_EVENTS.CYCLE_THEME), }, + { + label: MENU_LABELS.THEME, + submenu: THEMES.map((theme) => ({ + label: theme, + type: 'radio' as const, + click: send(MENU_EVENTS.SET_THEME, theme), + })), + }, { type: 'separator' }, { label: MENU_LABELS.ZOOM_IN, @@ -83,7 +95,7 @@ export function buildMenuTemplate(): MenuItemConstructorOptions[] { click: send(MENU_EVENTS.ZOOM_RESET), }, { type: 'separator' }, - { role: 'toggleDevTools' }, + ...(process.env.NODE_ENV === 'development' ? [{ role: 'toggleDevTools' as const }] : []), ], }, { diff --git a/apps/main-processor/src/settings/get-settings.ts b/apps/main-processor/src/settings/get-settings.ts new file mode 100644 index 0000000..0204a21 --- /dev/null +++ b/apps/main-processor/src/settings/get-settings.ts @@ -0,0 +1,19 @@ +import { readFile } from 'node:fs/promises'; +import { DEFAULT_SETTINGS } from '@package/shared-types'; +import { AppSettings } from '@package/shared-types'; +import { validateSettings, getSettingsPath } from '../utils/helper/setting-helper'; + +/*Loads settings from local file system */ +export async function getSettings(): Promise { + const settingsPath = getSettingsPath(); + try { + const data = await readFile(settingsPath, 'utf-8'); + const parsed = JSON.parse(data); + if (parsed && typeof parsed === 'object') { + return { ...DEFAULT_SETTINGS, ...validateSettings(parsed) }; + } + return DEFAULT_SETTINGS; + } catch { + return DEFAULT_SETTINGS; + } +} diff --git a/apps/main-processor/src/settings/save-settings.ts b/apps/main-processor/src/settings/save-settings.ts new file mode 100644 index 0000000..4aa64d7 --- /dev/null +++ b/apps/main-processor/src/settings/save-settings.ts @@ -0,0 +1,23 @@ +import { AppSettings } from '@package/shared-types'; +import { + validateSettings, + getSettingsPath, + writeSettingsAtomically, +} from '../utils/helper/setting-helper'; +import { getSettings } from './get-settings'; +import { runExclusive } from '../utils/helper/setting-helper'; + +/*Save settings changes to local file*/ +export async function saveSettings(settings: Partial): Promise { + return runExclusive(async () => { + const validatedSettings = validateSettings(settings); + const nextSettings: AppSettings = { ...(await getSettings()), ...validatedSettings }; + const settingsPath = getSettingsPath(); + try { + await writeSettingsAtomically(settingsPath, nextSettings); + return nextSettings; + } catch (error) { + throw new Error('Failed to save settings file', { cause: error }); + } + }); +} diff --git a/apps/main-processor/src/types/watch-file-types.ts b/apps/main-processor/src/types/watch-file-types.ts new file mode 100644 index 0000000..cc19e8a --- /dev/null +++ b/apps/main-processor/src/types/watch-file-types.ts @@ -0,0 +1,5 @@ +export type WatchFileOptions = { + onChange: () => void; + onDeleted?: () => void; + onError?: (error: Error) => void; +}; diff --git a/apps/main-processor/src/utils/constants/folder-constants.ts b/apps/main-processor/src/utils/constants/folder-constants.ts new file mode 100644 index 0000000..ef3305c --- /dev/null +++ b/apps/main-processor/src/utils/constants/folder-constants.ts @@ -0,0 +1,2 @@ +export const MAX_FOLDER_DEPTH = 10; +export const MAX_RESULTS = 500; diff --git a/apps/main-processor/src/utils/constants/path-constants.ts b/apps/main-processor/src/utils/constants/path-constants.ts index 1a20707..a8c72c6 100644 --- a/apps/main-processor/src/utils/constants/path-constants.ts +++ b/apps/main-processor/src/utils/constants/path-constants.ts @@ -11,4 +11,6 @@ export const PATHS = { export const MAX_RECENT = 20; +export const MAX_FOLDER_DEPTH = 10; + export const MARKDOWN_FILE_PATTERN = /\.(md|markdown)$/i; diff --git a/apps/main-processor/src/utils/constants/setting-constants.ts b/apps/main-processor/src/utils/constants/setting-constants.ts new file mode 100644 index 0000000..edf0b6c --- /dev/null +++ b/apps/main-processor/src/utils/constants/setting-constants.ts @@ -0,0 +1,14 @@ +import { AppSettings } from '@package/shared-types'; + +export const SETTINGS_KEYS = new Set([ + 'theme', + 'fontSize', + 'readingWidth', + 'lineNumbers', + 'customCss', + 'zoom', + 'recentFilesLimit', + 'showHiddenFiles', +]); + +export const READING_WIDTHS = new Set(['narrow', 'default', 'wide']); diff --git a/apps/main-processor/src/utils/helper/folder-search-helper.ts b/apps/main-processor/src/utils/helper/folder-search-helper.ts new file mode 100644 index 0000000..91a95ba --- /dev/null +++ b/apps/main-processor/src/utils/helper/folder-search-helper.ts @@ -0,0 +1,35 @@ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { MAX_FOLDER_DEPTH, MAX_RESULTS } from '../constants/folder-constants'; +import { MARKDOWN_FILE_PATTERN } from '@package/shared-constants'; + +/*It finds and collects Markdown file paths up to depth and result limits */ +export async function collectMarkdownFiles( + folderPath: string, + files: string[] = [], + currentDepth = 0 +): Promise { + if (currentDepth >= MAX_FOLDER_DEPTH || files.length >= MAX_RESULTS) return files; + + let entries; + try { + entries = await readdir(folderPath, { withFileTypes: true }); + } catch (error) { + const code = (error as { code?: string }).code; + if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') return files; + throw error; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.isSymbolicLink()) continue; + const fullPath = join(folderPath, entry.name); + if (entry.isDirectory()) { + await collectMarkdownFiles(fullPath, files, currentDepth + 1); + } else if (MARKDOWN_FILE_PATTERN.test(entry.name)) { + files.push(fullPath); + } + if (files.length >= MAX_RESULTS) break; + } + + return files; +} 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/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/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 3a6179b..8c73d60 100644 --- a/apps/preload/src/index.ts +++ b/apps/preload/src/index.ts @@ -1,8 +1,9 @@ -import { contextBridge, ipcRenderer, webUtils } 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); 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 100% rename from apps/renderer/tests/components/TabBar.test.tsx rename to apps/renderer/__tests__/components/TabBar.test.tsx 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 100% rename from apps/renderer/tests/hooks/useSettings.test.ts rename to apps/renderer/__tests__/hooks/useSettings.test.ts 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 similarity index 100% rename from apps/renderer/tests/renderer/drag-drop.test.ts rename to apps/renderer/__tests__/renderer/drag-drop.test.ts 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 100% rename from apps/renderer/tests/renderer/markdown.test.ts rename to apps/renderer/__tests__/renderer/markdown.test.ts 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 similarity index 100% rename from apps/renderer/tests/renderer/sanitize.test.ts rename to apps/renderer/__tests__/renderer/sanitize.test.ts 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..406194a 100644 --- a/apps/renderer/index.html +++ b/apps/renderer/index.html @@ -3,6 +3,8 @@ Markdown Reader + + + + + diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index 73cab94..7d209ca 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -13,9 +13,9 @@ import { SearchBar } from './components/SearchBar'; import { useSettings } from './hooks/useSettings'; import { StatusBar } from './components/StatusBar'; import { FileBrowser } from './components/FileBrowser'; +import { extractTOC } from './renderer/toc'; 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'; @@ -29,24 +29,35 @@ 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'; 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 [showToast, setShowToast] = useState(false); const contentRef=useRef(null); 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,openFolder,loadFileInTab,openFileDialog}=useFileActions({loadFile,dispatch}); + 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){ @@ -54,10 +65,20 @@ export default function App() { } },[folderTree,setFileBrowserOpen]); + 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, @@ -70,7 +91,9 @@ export default function App() { onCloseTab: closeActiveTab, onExportHtml:exportHtml, onExportPdf:exportPdf, - onExportDocx:exportDocx + onExportDocx:exportDocx, + onOpenSettings:()=>setSettingsOpen(true), + onSetTheme:setTheme }); useShortcuts({ @@ -79,12 +102,14 @@ useShortcuts({ onToggleFocusMode: toggleFocusMode, onToggleTheme: toggleTheme, onOpenSearch: openSearch, + onOpenFolderSearch: openFolderSearch, onCloseSearch: closeSearch, onZoomIn: increaseFontSize, onZoomOut: decreaseFontSize, onZoomReset: resetFontSize, onToggleSidebar: toggleSidebar, onToggleFileBrowser: toggleFileBrowser, + onOpenSettings:()=>setSettingsOpen(true) }); @@ -106,6 +131,31 @@ useShortcuts({ onClose={closeSearch} /> )} + {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 && ( )} + setSettingsOpen(false)} onChange={(partial)=>void updateSettings(partial)} appVersion={appVersion}/> )} diff --git a/apps/renderer/src/components/SearchBar.tsx b/apps/renderer/src/components/SearchBar.tsx index f7a3015..e94e46c 100644 --- a/apps/renderer/src/components/SearchBar.tsx +++ b/apps/renderer/src/components/SearchBar.tsx @@ -5,20 +5,32 @@ import { Icons } from '../utils/constants/icon-contants'; // search bar component export function SearchBar({ query, + folderQuery, matchCount, currentMatch, onQueryChange, onNext, onPrev, onClose, + mode = 'document', + folderResults = [], + isSearchingFolder = false, + onOpenFolderResult, + hasFolder = true, }: SearchBarProps) { const inputRef = useRef(null); - const [localQuery,setLocalQuery]=useState(query); + const isFolderMode = mode === 'folder'; + const activeQuery = (isFolderMode ? folderQuery : query) ?? ''; + const [localQuery,setLocalQuery]=useState(activeQuery); useEffect(() => { inputRef.current?.focus(); }, []); + useEffect(() => { + setLocalQuery(activeQuery); + }, [activeQuery]); + useEffect(()=>{ const handler=setTimeout(()=>{ onQueryChange(localQuery); @@ -26,8 +38,9 @@ export function SearchBar({ return ()=>clearTimeout(handler) },[localQuery,onQueryChange]); - const prevDisable=matchCount===0 || currentMatch<=1; - const nextDisable=matchCount===0 || currentMatch>=matchCount; + + const prevDisable=isFolderMode || matchCount===0 || currentMatch<=1; + const nextDisable=isFolderMode || matchCount===0 || currentMatch>=matchCount; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && e.shiftKey) { @@ -49,7 +62,8 @@ export function SearchBar({ const newButtonClass=(isDisable:boolean)=>`${btnClass} ${isDisable? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`; return ( -
+
+
setLocalQuery(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Search in the document" - aria-label="Search in document" + placeholder={isFolderMode ? "Search in folder" : "Search in the document"} + aria-label={isFolderMode ? "Search in folder" : "Search in document"} /> - {query && ( + {query && !isFolderMode && (
{matchCount > 0 ? ( @@ -74,12 +88,76 @@ export function SearchBar({ )}
)} -
- - - + {!isFolderMode && ( + <> + + + + )} + +
+ {isFolderMode && ( +
+ {!hasFolder && ( +
+ Open a folder to search. +
+ )} + {hasFolder && isSearchingFolder && ( +
+ Searching... +
+ )} + {hasFolder && + folderQuery && + !isSearchingFolder && + folderResults.length === 0 && ( +
+ No results +
+ )} + {folderResults.map((result) => ( + + ))} +
+ )}
); } diff --git a/apps/renderer/src/components/SettingsPanel.tsx b/apps/renderer/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..90ae936 --- /dev/null +++ b/apps/renderer/src/components/SettingsPanel.tsx @@ -0,0 +1,103 @@ +import { useEffect,useRef } from 'react'; +import { SettingsPanelProps } from '../types/component-types'; +import { Icons } from '../utils/constants/icon-contants'; +import { WIDTH_MAP } from '../types/component-types'; + +/*Displays the settings features in UI*/ +export function SettingsPanel({ + settings, + isOpen, + onClose, + onChange, + appVersion, +}: SettingsPanelProps) { + const dialogRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + const previouslyFocused = document.activeElement as HTMLElement | null; + dialogRef.current?.focus(); + return () => previouslyFocused?.focus(); + + }, [isOpen]); + + useEffect(()=>{ + if(!isOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + + }, [isOpen, onClose]) + if (!isOpen) return null; + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="settings-title" + className="w-full max-w-xl rounded-lg border border-border-theme bg-bg shadow-xl" + > +
+

+ Settings +

+ +
+ +
+
+ Reading width +
+ {(['narrow', 'default', 'wide'] as const).map((width) => ( + + ))} +
+
+ + + +