From 7b77ca2ea8a3ab26c1794bbf3f2aab6402855eff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mindaugas=20Kasparavic=CC=8Cius?= Date: Thu, 23 Jul 2026 18:20:59 +0300 Subject: [PATCH 1/5] feat: impfove mermaid preview ux, sidebar reordering --- DEVELOPMENT_PLAN.md | 15 ++- README.md | 8 +- e2e/copy-diff.spec.mjs | 7 +- e2e/format-hint.spec.mjs | 19 ++- e2e/sharing.spec.mjs | 5 +- e2e/view-toggles.spec.mjs | 16 ++- src/main/window.js | 8 ++ src/preload/index.js | 5 + src/renderer/src/App.vue | 3 +- src/renderer/src/components/AppToolbar.vue | 46 +++++-- src/renderer/src/components/DiffViewer.vue | 6 + .../src/components/FormatHintBanner.vue | 34 +++-- .../src/components/MermaidViewerDialog.vue | 120 +++++++++++------- src/renderer/src/components/SectionHeader.vue | 29 ++++- .../src/components/styles/AppToolbar.css | 8 +- .../src/components/styles/DiffViewer.css | 16 +++ .../components/styles/FormatHintBanner.css | 3 + .../components/styles/MermaidViewerDialog.css | 43 +++++-- .../src/components/styles/SectionHeader.css | 13 ++ src/renderer/src/icons.js | 6 + src/renderer/src/stores/diffStore.js | 55 +++++++- src/renderer/src/stores/settingsStore.js | 20 +++ src/renderer/src/types.js | 9 ++ tests/renderer/stores/diffStore.test.js | 47 +++++++ tests/renderer/stores/settingsStore.test.js | 33 +++++ 25 files changed, 462 insertions(+), 112 deletions(-) diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md index 1b72e49..3b86abd 100644 --- a/DEVELOPMENT_PLAN.md +++ b/DEVELOPMENT_PLAN.md @@ -154,9 +154,12 @@ First run: `npm install && npm run dev` every diagram type in real Chromium before adopting the dep). SVG is inserted via `DOMParser` + `replaceChildren`, never `innerHTML`/`v-html`; `securityLevel: 'strict'` (DOMPurify) is never lowered. -- [x] Live preview in the snippet editor + a resizable, zoom/pan diagram viewer; - diagram theme paired to the app theme (dark → `dark`, light → `default`), - re-rendered on theme switch so text never blends into the canvas +- [x] Live preview in the snippet editor + a zoom/pan diagram viewer, drag-resizable + from any of its four corners (`useResizable` + pure `utils/resizeRect.js`) and + auto-maximised when the app window enters fullscreen (main pushes + `window:fullscreen`, `useFullScreen` relays it); diagram theme paired to the + app theme (dark → `dark`, light → `default`), re-rendered on theme switch so + text never blends into the canvas - [x] Auto-detect for the snippet editor's syntax picker (`utils/detectLanguage.js`): distinctive, low-ambiguity signals for every offered language (JSON, Mermaid, SQL, Markdown, YAML/K8s, Python, shell, @@ -174,7 +177,11 @@ First run: `npm install && npm run dev` visibility, and user-raisable comparison-file / snippet size limits with safe defaults and hard ceilings (main enforces the file limit from it) - [x] Reorderable sidebar sections behind a shared `SectionHeader`; Saved / - External / Snippets each extracted into a self-contained component + External / Snippets each extracted into a self-contained component. Reorder + by dragging a whole header (`useSectionReorder`) or via its up/down + steppers; a single toolbar padlock freezes the arrangement + (`settings.sectionsLocked`, persisted — locked headers drop the drag handle + and steppers) - [x] Diff search gains match-case, whole-word, and safety-limited regex (`utils/searchRegex.js` refuses over-long / catastrophic patterns) - [x] Partial paste mode: diff pasted text against a dropped/chosen file diff --git a/README.md b/README.md index 9a0739f..cfd90e0 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,13 @@ a hard promise: it never touches the network. - **Saved diffs** — encrypted, auto-expiring, organized into categories. - **Share** a diff as a sealed, signed file only its intended recipient can open. - **Snippets** — an encrypted, tagged text library with per-language - highlighting and live **Mermaid** diagram rendering. + highlighting and live **Mermaid** diagram rendering, in a viewer you can drag + bigger from any corner (and that fills the window when the app goes fullscreen). - **Tools** — Base64, JSON / XML / SQL format + validate, and passphrase text encryption. -- **Yours to arrange** — five themes, a rearrangeable sidebar, and adjustable - limits, all remembered between sessions. +- **Yours to arrange** — five themes, a sidebar whose sections you drag to + reorder (and can lock in place), and adjustable limits, all remembered between + sessions. diff --git a/e2e/copy-diff.spec.mjs b/e2e/copy-diff.spec.mjs index 5a23f5d..08f0e43 100644 --- a/e2e/copy-diff.spec.mjs +++ b/e2e/copy-diff.spec.mjs @@ -39,9 +39,10 @@ test('Copy diff writes a unified patch to the OS clipboard', async ({ app, page test('identical sides show a No differences state and copy nothing', async ({ page }) => { await pasteCompare(page, 'same\nlines\n', 'same\nlines\n') - await expect(page.locator('.stats .identical')).toHaveText('No differences') - await expect(page.locator('.stats .add')).toBeHidden() - await expect(page.locator('.stats .del')).toBeHidden() + // The "no differences" note is a row label over the diff panes, not a toolbar + // stat — and the toolbar shows no empty +0/−0 counts in that state. + await expect(page.locator('.diff-viewer .identical-row')).toContainText('No differences') + await expect(page.locator('.stats')).toBeHidden() // Copy diff on an identical comparison explains itself rather than copying an // empty patch. diff --git a/e2e/format-hint.spec.mjs b/e2e/format-hint.spec.mjs index fa77b4e..5c4d9ad 100644 --- a/e2e/format-hint.spec.mjs +++ b/e2e/format-hint.spec.mjs @@ -2,7 +2,8 @@ import { test, expect } from './fixtures.mjs' // The "looks like JSON/XML — pretty-print it?" banner is driven by a store // getter over live content, and Format rewrites the side in place. A launch -// proves the banner renders above the diff, formats, and then clears itself. +// proves the single merged banner renders above the diff, formats (one side or +// both), and then clears itself — never two stacked strips. async function pasteCompare(page, left, right) { await page.getByRole('button', { name: 'Paste text' }).click() await page.getByPlaceholder('Paste original text here').fill(left) @@ -26,6 +27,22 @@ test('minified JSON offers to format, and formatting clears the banner', async ( await expect(page.locator('div.hint')).toBeHidden() }) +test('two minified sides show ONE merged banner and Format both cleans them up', async ({ + page +}) => { + await pasteCompare(page, '{"b":2,"a":1}', '{"d":4,"c":3}') + + // The whole point of the merge: a single banner, never two stacked strips. + const banner = page.locator('div.hint') + await expect(banner).toHaveCount(1) + await expect(banner).toContainText('Both sides look like JSON') + + await banner.getByRole('button', { name: 'Format both' }).click() + await expect(page.getByText('"a": 1').first()).toBeVisible() + await expect(page.getByText('"c": 3').first()).toBeVisible() + await expect(page.locator('div.hint')).toBeHidden() // both pretty → banner clears +}) + test('Dismiss hides the banner without changing the content', async ({ page }) => { await pasteCompare(page, '{"x":1,"y":2}', 'plain text') const banner = page.locator('div.hint') diff --git a/e2e/sharing.spec.mjs b/e2e/sharing.spec.mjs index fb56ff0..dcf45a1 100644 --- a/e2e/sharing.spec.mjs +++ b/e2e/sharing.spec.mjs @@ -89,11 +89,14 @@ test('two peers exchange keys, share a sealed diff, and import it', async () => expect(sealed).toBeTruthy() // 4. Bob imports it — it lands in External diffs, credited to Alice. + const external = pageB.locator('.sidebar-section', { hasText: 'External diffs' }) + const externalHelp = external.getByText(/Diffs shared by someone else appear here/) + await expect(externalHelp).toBeVisible() // empty-state help, before any import await stubOpenDialog(appB, join(exchange, sealed)) await pageB.getByRole('button', { name: 'Import', exact: true }).click() await expect(pageB.getByText(/Imported "Shared work" from Alice/)).toBeVisible() - const external = pageB.locator('.sidebar-section', { hasText: 'External diffs' }) await expect(external.getByText('Shared work')).toBeVisible() + await expect(externalHelp).toBeHidden() // the help text clears once a diff is there // 5. Manage trusted keys on Alice: rename Bob, then remove him. await openMenu(pageA, 'Security', 'Manage Trusted Keys') diff --git a/e2e/view-toggles.spec.mjs b/e2e/view-toggles.spec.mjs index 0f749af..b66b25e 100644 --- a/e2e/view-toggles.spec.mjs +++ b/e2e/view-toggles.spec.mjs @@ -12,10 +12,22 @@ async function pasteCompare(page, left, right) { test('ignore-whitespace turns a whitespace-only diff into "No differences"', async ({ page }) => { await pasteCompare(page, 'alpha\nbeta', 'alpha \nbeta') // trailing space, line 1 - await expect(page.locator('.stats .identical')).toBeHidden() // a change, for now + const identical = page.locator('.diff-viewer .identical-row') + await expect(identical).toBeHidden() // a change, for now await page.getByLabel('Ignore whitespace').check() - await expect(page.locator('.stats .identical')).toHaveText('No differences') + await expect(identical).toContainText('No differences') +}) + +test('the Paste text button names its destination so the toggle is explicit', async ({ page }) => { + const toggle = page.locator('.toolbar .actions button').filter({ hasText: /Paste text|File mode/ }) + await expect(toggle).toHaveText('Paste text') // files mode: offers paste + await toggle.click() + await expect(page.getByPlaceholder('Paste original text here')).toBeVisible() + await expect(toggle).toHaveText('File mode') // now offers the way back + await toggle.click() + await expect(toggle).toHaveText('Paste text') // back to files mode + await expect(page.getByPlaceholder('Paste original text here')).toBeHidden() }) test('Swap flips additions and deletions', async ({ page }) => { diff --git a/src/main/window.js b/src/main/window.js index adaf8fd..84d5361 100644 --- a/src/main/window.js +++ b/src/main/window.js @@ -119,6 +119,14 @@ export function createWindow() { if (state.maximized) win.maximize() trackWindowState(win) + // Push the app-window fullscreen state to the renderer so views that fill the + // window (the Mermaid viewer) can maximise themselves in step with it. No + // renderer-observable DOM signal exists for OS-level window fullscreen, so it + // comes from these BrowserWindow events. + const sendFullScreen = () => win.webContents.send('window:fullscreen', win.isFullScreen()) + win.on('enter-full-screen', sendFullScreen) + win.on('leave-full-screen', sendFullScreen) + if (DEV_URL) { win.loadURL(DEV_URL) } else { diff --git a/src/preload/index.js b/src/preload/index.js index 3c680b7..41578b9 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -82,5 +82,10 @@ contextBridge.exposeInMainWorld('api', { // App-menu actions (Open Left, Swap, …) arrive from the main process. onMenuAction: (handler) => { ipcRenderer.on('menu:action', (_e, action) => handler(action)) + }, + // App-window fullscreen state changes (main pushes true/false). Read by the + // Mermaid viewer so it can fill the window when the app goes fullscreen. + onFullScreenChange: (handler) => { + ipcRenderer.on('window:fullscreen', (_e, value) => handler(value)) } }) diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index f83f6ad..e8b76cb 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -105,8 +105,7 @@ const { diff --git a/src/renderer/src/components/AppToolbar.vue b/src/renderer/src/components/AppToolbar.vue index 0f86e72..63a801e 100644 --- a/src/renderer/src/components/AppToolbar.vue +++ b/src/renderer/src/components/AppToolbar.vue @@ -2,21 +2,31 @@ // Top bar: diff stats, the display toggles, the document actions, and the // theme switch. Every action here has a menu twin (src/main/menu.js and // MenuBar.vue) — this is the pointer-friendly half. +import { computed } from 'vue' import { useDiffStore } from '../stores/diffStore' +import { useSettingsStore } from '../stores/settingsStore' import { MOD } from '../keys' import AppIcon from './AppIcon.vue' const store = useDiffStore() +const settings = useSettingsStore() + +// The button names its destination, so it's explicit that pressing it again in +// paste mode returns to comparing files. +const inPaste = computed(() => store.mode === 'paste') +const pasteToggleLabel = computed(() => (inPaste.value ? 'File mode' : 'Paste text')) +const pasteToggleTitle = computed(() => + inPaste.value ? `Back to comparing files (${MOD}+T)` : `Compare pasted text (${MOD}+T)` +) diff --git a/src/renderer/src/components/DiffViewer.vue b/src/renderer/src/components/DiffViewer.vue index 598a9ee..b96c3a3 100644 --- a/src/renderer/src/components/DiffViewer.vue +++ b/src/renderer/src/components/DiffViewer.vue @@ -99,6 +99,12 @@ onBeforeUnmount(() => {