diff --git a/README.md b/README.md index 5db0852..67a265d 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Builds are **unsigned**, so SmartScreen and Gatekeeper warn on first launch (the | **Keep** | Saved diffs: encrypted, tagged, optionally auto-expiring. Drag a row onto another to arrange the list yourself; starred rows stay above the rest. Your open tabs come back on the next launch, and the strip can be told to close the oldest comparison to make room for a new one. | | **Share** | One signed file only the recipients you ticked can open, carrying the expiry you chose so every copy dies at the same moment. Give a trusted key an email address and Diff Bro opens an addressed message in your own mail app with the sealed file on the clipboard — it never sends anything itself. The key swap rides the same rails: email your key from the My key dialog, and a key copied out of any chat app is offered — fingerprint first — when you press + Trusted key. | | **Export as image** | A real screenshot of the diff view — your theme, panes and highlighting — cropped to the change and stitched if it's taller than the window. Snippets go the same way, and a Mermaid snippet leaves as its rendered diagram. | -| **Snippets** | An encrypted, tagged text library you can drag straight into the diff pane — two snippets compare like any two files, and editing one updates the comparison on screen. Per-language highlighting, live Mermaid (readable light or dark whatever the app is wearing), Markdown/Jira preview — headings, nested lists, task lists, tables, strikethrough and code, with a formatting row that writes them for you — and secret snippets that render as `****`. Every edit keeps the version it replaced — History in the snippet window lists them by timestamp, each diffed against its predecessor, any of them a copy away. Name one `Standup {{today}}` and the placeholder resolves as you save — `{{now}}`, `{{week}}`, `{{weekday}}` and the rest are listed under the field as you type. Naming is completed inline: type a few characters and the rest of the shared head of your existing names appears ahead of the caret, Tab to take it. Drag a row onto another to arrange the library by hand, and right-click one to give it a colour — six of them, painted across the whole row, so a handful of snippets group by eye across whatever tags they carry. Nothing filters or searches by it; it is there to be seen. | +| **Snippets** | An encrypted, tagged text library you can drag straight into the diff pane — two snippets compare like any two files, and editing one updates the comparison on screen. Per-language highlighting, live Mermaid (readable light or dark whatever the app is wearing), Markdown/Jira rendered view that you can **type straight into** — headings, nested lists, task lists, tables, strikethrough and code, with a formatting row that writes them for you, and task boxes you tick where they are drawn — and secret snippets that render as `****`. Every edit keeps the version it replaced — History in the snippet window lists them by timestamp, each diffed against its predecessor, any of them a copy away. Name one `Standup {{today}}` and the placeholder resolves as you save — `{{now}}`, `{{week}}`, `{{weekday}}` and the rest are listed under the field as you type. Naming is completed inline: type a few characters and the rest of the shared head of your existing names appears ahead of the caret, Tab to take it. Drag a row onto another to arrange the library by hand, and right-click one to give it a colour — six of them, painted across the whole row, so a handful of snippets group by eye across whatever tags they carry. Nothing filters or searches by it; it is there to be seen. | | **Quick look-up** | A global shortcut searches your snippets and diffs without raising the app; copy one straight to the clipboard, or capture a new one with `Ctrl/Cmd+N` — whatever you searched for becomes its name, and the body is syntax-coloured as you type in whatever language it turns out to be. | | **Stays out of the way** | On Windows, closing the window keeps Diff Bro in the notification area so the quick look-up shortcut still answers — right-click the icon to exit, and turn either that or start-at-sign-in off in Settings ▸ Desktop. | | **Language** | Every menu, dialog and label reads from one message catalogue, and Settings ▸ Appearance switches it — menus included, without a restart. English ships today; a new language is a data file, not a code change. | @@ -63,6 +63,7 @@ Builds are **unsigned**, so SmartScreen and Gatekeeper warn on first launch (the | **Diagrams** | Two Mermaid files compare as a picture, not as text — one diagram carrying both revisions, so an inserted node reads as one change instead of a rewrite. | | **Tools** | JSON, Base64, UUID, JWT, Epoch, URL, Lines, XML, checksums, a regex tester, find & replace, text encryption — rich panels, not blank text boxes. All of them live in their own sidebar section; star the ones you reach for and they stay at the top. | | **Merge conflicts** | Registered as git's `difftool` **and** `mergetool`: `git mergetool` opens the list of every conflicted file first — what is left, what is done, and how many regions each still holds. Take a whole side from a row without opening anything, or pick one for a real three-way view: the two branches either side, the file you are producing in the middle, and that middle one is a full editor. Each side is labelled with the branch it came from and read out of git's index, so no `<<<<<<<` ever reaches the screen; a button on each pane's inner edge moves that side across, F7 walks the conflicts, and where neither side is right you just type the answer. Resolve them in any order — saving returns you to the list, and closing it never ends the merge. It writes each merged file back and tells git it is done — the only files Diff Bro writes over; everything else it produces is a new file you picked the place for. | +| **Open with** | Diff Bro appears in Finder's **Open With** menu and Explorer's **Open with** list for text and data files — `.txt`, `.md`, `.json`, `.xml`, `.yaml`, `.csv`, `.log`, `.diff` and the rest. The first file opens a tab and takes the left pane; the next joins it on the right; the one after that starts a new tab. Select two at once and they open as one comparison. It is offered, never imposed: Diff Bro does not make itself the default handler for anything, so double-clicking a JSON still opens whatever it always did. | | **Terminal** | `diffbro compare a.json b.json` opens a comparison in the running app, and either side can name a git revision instead of a file — `diffbro compare HEAD~1:src/app.js src/app.js` reads the old copy straight out of the repository, so you never have to produce one first. `diffbro open` raises the app, `diffbro backup ` writes an encrypted archive. No port, no daemon. | | **Yours to arrange** | Twenty themes (Nord, Sepia, Solar, Nyan, Matrix, Volcano, Tide, Graphite, plus accessibility-grade Contrast and Beacon), shared tags, adjustable limits. | @@ -72,7 +73,7 @@ Builds are **unsigned**, so SmartScreen and Gatekeeper warn on first launch (the - **Drag & drop** files onto the window; it warns before discarding unsaved work. - **Ctrl/Cmd+V** pastes straight into a comparison — including pasted text against a real file. - **Copy diff** puts a git-style unified patch on the clipboard. -- **Quick look-up keys** — ↑/↓ browse, **→** steps into a preview or the tools, **←** steps back out, **Enter** opens, **Ctrl/Cmd+N** captures a new snippet without raising the app (or arrow to the _Create snippet_ row), **Tab** accepts the inline name completion, and **Ctrl/Cmd+Enter** saves it. The card grows for writing, a click opens whatever row it lands on, and a snippet you were part-way through is still there the next time you summon it. A Jira or Markdown snippet gets the same formatting row the editor gives it, with the rendered form drawn beside the syntax as you type. +- **Quick look-up keys** — ↑/↓ browse, **→** steps into a preview or the tools, **←** steps back out, **Enter** opens, **Ctrl/Cmd+N** captures a new snippet without raising the app (or arrow to the _Create snippet_ row), **Tab** accepts the inline name completion, and **Ctrl/Cmd+Enter** saves it. The card grows for writing, a click opens whatever row it lands on, and a snippet you were part-way through is still there the next time you summon it. Drag it aside by its search strip, its title bar or the hint row along the bottom when it is covering something you need to read. A Jira or Markdown snippet gets the same formatting row the editor gives it, with the rendered form drawn beside the syntax as you type (the launcher's preview stays read-only — the editable one is in the snippet window). - **Resizable dialogs** — the snippet editor and tool windows resize from any edge and remember their size; existing snippets open read-only until you press Edit. - **Save a tool's output** — anything a tool produced goes straight into the snippet library from its own window; you supply the name, the app fills the rest. - **Uniform snippet names** — every name is sentence-cased on save, so a library grown over months still reads consistently. diff --git a/docs/brand/roadmap.svg b/docs/brand/roadmap.svg index ca6054c..3323ff3 100644 --- a/docs/brand/roadmap.svg +++ b/docs/brand/roadmap.svg @@ -5,7 +5,7 @@ sidebar. Track hues are real theme accents from src/renderer/src/utils/themes.js (Dim, Bloom, Neon, Beacon) — colour depth encodes sequence: solid now, faded later. Keep this in step with the items in docs/roadmap.md. --> - + @@ -27,7 +27,7 @@ Diff Bro roadmap - Five tracks. Depth of colour is sequence — solid first, faded last. + Seven tracks. Depth of colour is sequence — solid first, faded last. utils/lockfile/ + + + Snippets + 3 + + + + Rendered view is editable + + + Markdown + Jira, one block tree + + + Paste is text/plain only + + + utils/domToBlocks.js + + + + OS integration + 3 + + + + Open with, from Finder / Explorer + + + Left, then right, then a new tab + + + Offered, never the default handler + + + main/cli.js · parseOpenWith + diff --git a/docs/roadmap.md b/docs/roadmap.md index 882febf..8437182 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -291,6 +291,77 @@ Done. Anchored coach marks over the real controls, split 6 + 4. --- +## OS integration + +```mermaid +flowchart TD + finder["Finder / Explorer
Open with"] --> ev{how it arrives} + ev -->|macOS| of["app.on('open-file')
fires BEFORE whenReady"] + ev -->|Windows / Linux| argv["argv, cold or second-instance"] + of --> route + argv --> route["parseOpenWith → allowCliPath → deliver"] + route --> cycle{active tab} + cycle -->|opened by this flow,
left filled, right empty| right["fill RIGHT"] + cycle -->|anything else| left["NEW TAB, fill LEFT"] +``` + +Done. Diff Bro is in the OS "Open with" list for text and data files. + +- `parseOpenWith` sits BESIDE `parseCli`, never inside it: `cliWords` strips the + first path as the entry point, which is exactly the slot the OS uses +- Fixed a launch-time crash it exposed — two files selected at once parsed the + second as a verb, and `index.js` reported the error and `exit(1)` before a + window existed +- Unpackaged, argv[1] is the entry SCRIPT, not a document. Read as one it opened + the app's own source +- The 1-left / 2-right / 3-new-tab cycle is DERIVED from tab state, not counted: + a counter desyncs the moment a tab is closed between two opens +- Finder sends one event per file, so the placements are serialised — otherwise + two files that should compare land in two tabs +- `role: Viewer` / `rank: Alternate`: offered, never the default handler +- Open: Linux `.desktop` MIME registration is emitted but unverified +- Open: `.xlsx` is deliberately not associated + +--- + +## Snippets + +```mermaid +stateDiagram-v2 + [*] --> view: open a snippet + view --> plain: Edit, then Plain + view --> rendered: Edit, then Rendered + plain --> rendered: toggle + rendered --> plain: toggle + plain --> text: Monaco edits the source + rendered --> dom: caret edits the DOM + dom --> text: domToBlocks + serialize + text --> [*]: Save +``` + +Done. The rendered view of a Markdown or Jira snippet is editable — WYSIWYG, not +a preview. + +- Two parsers already emitted ONE block tree, so the read-back is shared + (`domToBlocks`) and only the markers differ (`markdownSerialize`, + `jiraSerialize`) +- The DOM is the source of truth while typing; the tree is re-parsed only when + the text changes from outside, or the caret jumps to offset 0 on every key +- An external change REBUILDS the subtree rather than patching it — typing left + Vue's vdom stale, and patching from a stale tree corrupts it +- Paste reads `text/plain` only. The default inserts the clipboard's `text/html`, + which is markup landing in the DOM behind Vue's back (rule 8) +- The toolbar edits the DOM here, not the source: an offset into rendered text + does not map onto one into markup — `**bold**` is 8 characters and shows 4 +- Task boxes tick where they are drawn, writing `- [x]` back +- Round trips normalise spelling, never structure: `* item` → `- item`, + `_em_` → `*em*`, `bq.` → `{quote}`. Asserted one by one, so a widening fails +- Open: `{code:java}` still loses its language — `parseJira` drops it before the + tree exists +- Open: undo/redo does not span the two views; Monaco owns its own stack + +--- + ## Toolbar ```mermaid diff --git a/docs/screenshots/themes/rendered-editor-amber.png b/docs/screenshots/themes/rendered-editor-amber.png new file mode 100644 index 0000000..2f18a20 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-amber.png differ diff --git a/docs/screenshots/themes/rendered-editor-beacon.png b/docs/screenshots/themes/rendered-editor-beacon.png new file mode 100644 index 0000000..85ec4e6 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-beacon.png differ diff --git a/docs/screenshots/themes/rendered-editor-bloom.png b/docs/screenshots/themes/rendered-editor-bloom.png new file mode 100644 index 0000000..611ab27 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-bloom.png differ diff --git a/docs/screenshots/themes/rendered-editor-contrast.png b/docs/screenshots/themes/rendered-editor-contrast.png new file mode 100644 index 0000000..91d6246 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-contrast.png differ diff --git a/docs/screenshots/themes/rendered-editor-dark.png b/docs/screenshots/themes/rendered-editor-dark.png new file mode 100644 index 0000000..72180d4 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-dark.png differ diff --git a/docs/screenshots/themes/rendered-editor-dim.png b/docs/screenshots/themes/rendered-editor-dim.png new file mode 100644 index 0000000..c9793a1 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-dim.png differ diff --git a/docs/screenshots/themes/rendered-editor-ember.png b/docs/screenshots/themes/rendered-editor-ember.png new file mode 100644 index 0000000..31e96b3 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-ember.png differ diff --git a/docs/screenshots/themes/rendered-editor-graphite.png b/docs/screenshots/themes/rendered-editor-graphite.png new file mode 100644 index 0000000..fe56182 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-graphite.png differ diff --git a/docs/screenshots/themes/rendered-editor-light.png b/docs/screenshots/themes/rendered-editor-light.png new file mode 100644 index 0000000..bcda587 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-light.png differ diff --git a/docs/screenshots/themes/rendered-editor-linen.png b/docs/screenshots/themes/rendered-editor-linen.png new file mode 100644 index 0000000..4d393f4 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-linen.png differ diff --git a/docs/screenshots/themes/rendered-editor-matrix.png b/docs/screenshots/themes/rendered-editor-matrix.png new file mode 100644 index 0000000..274be78 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-matrix.png differ diff --git a/docs/screenshots/themes/rendered-editor-meridian.png b/docs/screenshots/themes/rendered-editor-meridian.png new file mode 100644 index 0000000..40080cf Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-meridian.png differ diff --git a/docs/screenshots/themes/rendered-editor-neon.png b/docs/screenshots/themes/rendered-editor-neon.png new file mode 100644 index 0000000..acb1223 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-neon.png differ diff --git a/docs/screenshots/themes/rendered-editor-nord.png b/docs/screenshots/themes/rendered-editor-nord.png new file mode 100644 index 0000000..96ae96f Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-nord.png differ diff --git a/docs/screenshots/themes/rendered-editor-nyan.png b/docs/screenshots/themes/rendered-editor-nyan.png new file mode 100644 index 0000000..6f983e8 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-nyan.png differ diff --git a/docs/screenshots/themes/rendered-editor-sepia.png b/docs/screenshots/themes/rendered-editor-sepia.png new file mode 100644 index 0000000..f51a4b7 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-sepia.png differ diff --git a/docs/screenshots/themes/rendered-editor-solar.png b/docs/screenshots/themes/rendered-editor-solar.png new file mode 100644 index 0000000..f8d9e85 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-solar.png differ diff --git a/docs/screenshots/themes/rendered-editor-tide.png b/docs/screenshots/themes/rendered-editor-tide.png new file mode 100644 index 0000000..d31f013 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-tide.png differ diff --git a/docs/screenshots/themes/rendered-editor-vector.png b/docs/screenshots/themes/rendered-editor-vector.png new file mode 100644 index 0000000..1319fc8 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-vector.png differ diff --git a/docs/screenshots/themes/rendered-editor-volcano.png b/docs/screenshots/themes/rendered-editor-volcano.png new file mode 100644 index 0000000..ad0da72 Binary files /dev/null and b/docs/screenshots/themes/rendered-editor-volcano.png differ diff --git a/docs/security.md b/docs/security.md index 21aa226..7c9e53f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -125,6 +125,24 @@ merely invents is refused, and reads under `userData` are denied outright — so compromised renderer can't turn `file:read` into an arbitrary-file-read primitive (SSH keys, tokens, or the key files themselves on installs with no OS keychain). +### Files the OS hands over + +An "Open with" launch is a second instance carrying paths and no verb. The paths +come from the OS, never from the renderer, and they are vouched for in MAIN with +`allowCliPath` before the command is delivered — the same bounded allow-list +`diffbro compare` already uses, so `file:read` will serve them and nothing else. +No IPC handler was added for it: the command rides the existing `cli:command` +channel and its `cli:ready` pending queue. + +Arriving from Finder makes a file no more trusted than one picked in the app. It +goes through the same `file:read`, the same size caps and the same adapter +validation. `parseOpenWith` itself is pure — it decides on SHAPE and never +touches the filesystem, so existence and permission stay the fence's business. + +The associations are declared `role: Viewer` / `rank: Alternate`, which puts the +app in the Open-with LIST without making it the default handler for any +extension. + ## Saved diffs (vault) Saved comparisons are AES-256-GCM encrypted at rest with an install-specific key @@ -344,6 +362,29 @@ bundle's shape and enforces count/size caps (`validateSnippetBundle`) before the renderer touches it, so a malformed-but-decryptable file can't half-write state or blow the localStorage quota. The same check guards the config-restore path. +### Editing the rendered view + +A Markdown or Jira snippet's rendered view is editable — the caret sits in real +`contenteditable` DOM, and every edit is read back out and re-serialized to +markup. Two things keep that inside rule 8 (no injection sinks): + +- **Rendering stays interpolation.** The block tree is drawn by the same + `JiraRendered`/`JiraInline` components the read-only preview uses — `v-for` and + text interpolation, never `v-html` or `innerHTML`. `contenteditable` lets the + _user_ mutate the DOM; it does not parse a string into markup. +- **Paste reads `text/plain` and nothing else.** A default paste into a + contenteditable inserts the clipboard's `text/html` flavour, which is + attacker-authored markup (``, `hello')) + expect(blocks).toEqual([{ type: 'paragraph', lines: [[{ type: 'text', value: 'hello' }]] }]) + }) + + it('degrades an unknown inline element to its text', () => { + const blocks = domToBlocks(root('

ab

')) + expect(blocks[0].lines[0]).toEqual([ + { type: 'text', value: 'a' }, + { type: 'text', value: 'b' } + ]) + }) + + it('skips a block that browsers leave behind as empty', () => { + expect(domToBlocks(root('

x

'))).toEqual([ + { type: 'paragraph', lines: [[{ type: 'text', value: 'x' }]] } + ]) + }) +}) + +describe('domToBlocks — shapes the browser leaves behind', () => { + // blockOf only routes to tableBlock when a table is present. + it('drops a table wrapper holding no table', () => { + expect(domToBlocks(root('
'))).toEqual([]) + }) + + it('reads a list item with no data-depth as the first level', () => { + const blocks = domToBlocks(root('')) + expect(blocks[0].items[0].depth).toBe(1) + }) + + it('reads a blockquote with no inner wrapper', () => { + const blocks = domToBlocks( + root('

in

') + ) + expect(blocks[0].children).toEqual([ + { type: 'paragraph', lines: [[{ type: 'text', value: 'in' }]] } + ]) + }) + + it('drops an empty text node rather than emitting an empty run', () => { + const el = root('

x

') + el.querySelector('p').appendChild(document.createTextNode('')) + expect(el.querySelector('p').childNodes.length).toBe(2) + expect(domToBlocks(el)[0].lines[0]).toEqual([{ type: 'text', value: 'x' }]) + }) + + it('ignores a comment node', () => { + const el = root('

x

') + el.querySelector('p').appendChild(document.createComment('note')) + expect(domToBlocks(el)[0].lines[0]).toEqual([{ type: 'text', value: 'x' }]) + }) +}) diff --git a/tests/renderer/utils/jiraSerialize.test.js b/tests/renderer/utils/jiraSerialize.test.js new file mode 100644 index 0000000..78a3c22 --- /dev/null +++ b/tests/renderer/utils/jiraSerialize.test.js @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest' +import { serializeJira } from '../../../src/renderer/src/utils/jiraSerialize' +import { parseJira } from '../../../src/renderer/src/utils/jiraRender' + +const text = (value) => ({ type: 'text', value }) + +describe('serializeJira', () => { + it('returns an empty string for an empty tree', () => { + expect(serializeJira([])).toBe('') + expect(serializeJira(null)).toBe('') + }) + + it('writes headings as hN.', () => { + const blocks = [ + { type: 'heading', level: 1, inlines: [text('One')] }, + { type: 'heading', level: 3, inlines: [text('Three')] } + ] + expect(serializeJira(blocks)).toBe('h1. One\n\nh3. Three') + }) + + it('writes every inline marker', () => { + const blocks = [ + { + type: 'paragraph', + lines: [ + [ + { type: 'strong', inlines: [text('b')] }, + text(' '), + { type: 'em', inlines: [text('i')] }, + text(' '), + { type: 'ins', inlines: [text('u')] }, + text(' '), + { type: 'del', inlines: [text('s')] }, + text(' '), + { type: 'code', value: 'c' } + ] + ] + } + ] + expect(serializeJira(blocks)).toBe('*b* _i_ +u+ -s- {{c}}') + }) + + it('writes a link, collapsing the pipe when label and href match', () => { + const blocks = [ + { + type: 'paragraph', + lines: [ + [{ type: 'link', label: 'Docs', href: 'https://x.test' }], + [{ type: 'link', label: 'https://x.test', href: 'https://x.test' }] + ] + } + ] + expect(serializeJira(blocks)).toBe('[Docs|https://x.test]\n[https://x.test]') + }) + + // jiraRender reads depth off the marker's length, not indentation. + it('repeats the marker for depth', () => { + const blocks = [ + { + type: 'list', + ordered: false, + items: [ + { depth: 1, inlines: [text('top')] }, + { depth: 2, inlines: [text('nested')] }, + { depth: 3, inlines: [text('deeper')] } + ] + } + ] + expect(serializeJira(blocks)).toBe('* top\n** nested\n*** deeper') + }) + + it('uses # for an ordered list', () => { + const blocks = [ + { + type: 'list', + ordered: true, + items: [ + { depth: 1, inlines: [text('a')] }, + { depth: 2, inlines: [text('b')] } + ] + } + ] + expect(serializeJira(blocks)).toBe('# a\n## b') + }) + + // Jira has no task syntax; parseJira would render the brackets literally. + it('drops a task box', () => { + const blocks = [ + { + type: 'list', + ordered: false, + items: [{ depth: 1, task: true, checked: true, inlines: [text('done')] }] + } + ] + expect(serializeJira(blocks)).toBe('* done') + }) + + it('fences a quote, because the tree can hold more than one line', () => { + const blocks = [ + { + type: 'quote', + children: [ + { type: 'heading', level: 2, inlines: [text('Title')] }, + { type: 'paragraph', lines: [[text('body')]] } + ] + } + ] + expect(serializeJira(blocks)).toBe('{quote}\nh2. Title\n\nbody\n{quote}') + }) + + it('fences a code block', () => { + const blocks = [{ type: 'code', code: 'const a = 1\nconst b = 2' }] + expect(serializeJira(blocks)).toBe('{code}\nconst a = 1\nconst b = 2\n{code}') + }) + + it('writes a table with a || header and no alignment row', () => { + const blocks = [ + { + type: 'table', + align: [], + head: [[text('a')], [text('b')]], + rows: [[[text('1')], [text('2')]]] + } + ] + expect(serializeJira(blocks)).toBe('|| a || b ||\n| 1 | 2 |') + }) + + it('writes a headerless table, which Jira renders too', () => { + const blocks = [{ type: 'table', align: [], head: [], rows: [[[text('1')], [text('2')]]] }] + expect(serializeJira(blocks)).toBe('| 1 | 2 |') + }) + + it('separates blocks with a blank line', () => { + const blocks = [ + { type: 'heading', level: 1, inlines: [text('T')] }, + { type: 'paragraph', lines: [[text('p')]] } + ] + expect(serializeJira(blocks)).toBe('h1. T\n\np') + }) + + it('is stable when re-parsed and re-serialized', () => { + const src = [ + 'h1. Title', + '', + 'Some *bold* and a [link|https://x.test].', + '', + '* one', + '** nested', + '', + '# first', + '# second', + '', + '{quote}', + 'quoted', + '{quote}', + '', + '{code}', + 'code()', + '{code}', + '', + '|| a || b ||', + '| 1 | 2 |' + ].join('\n') + const once = serializeJira(parseJira(src)) + expect(serializeJira(parseJira(once))).toBe(once) + }) +}) + +// domToBlocks reads a DOM mid-edit, so a half-formed block is a real input. +describe('serializeJira — malformed trees', () => { + it('survives blocks missing their fields', () => { + expect( + serializeJira([ + { type: 'heading' }, + { type: 'paragraph' }, + { type: 'list' }, + { type: 'table' }, + { type: 'code' }, + { type: 'quote' } + ]) + ).toBe('h1. \n\n\n\n\n\n\n\n{code}\n\n{code}\n\n{quote}\n\n{quote}') + }) + + it('treats an unknown block as a paragraph', () => { + expect(serializeJira([{ type: 'mystery' }])).toBe('') + }) + + it('survives a block with no type at all', () => { + expect(serializeJira([null])).toBe('') + }) + + it('clamps a heading level into range', () => { + expect(serializeJira([{ type: 'heading', level: 99, inlines: [] }])).toBe('h6. ') + expect(serializeJira([{ type: 'heading', level: 0, inlines: [] }])).toBe('h1. ') + }) + + it('survives inline nodes missing their fields', () => { + expect( + serializeJira([ + { + type: 'paragraph', + lines: [[{ type: 'code' }, { type: 'link' }, { type: 'text' }, { type: 'mystery' }]] + } + ]) + ).toBe('{{}}[]') + }) + + it('defaults a list item with no depth to the first level', () => { + expect( + serializeJira([{ type: 'list', items: [{ inlines: [{ type: 'text', value: 'x' }] }] }]) + ).toBe('* x') + }) +}) diff --git a/tests/renderer/utils/markdownSerialize.test.js b/tests/renderer/utils/markdownSerialize.test.js new file mode 100644 index 0000000..de343a3 --- /dev/null +++ b/tests/renderer/utils/markdownSerialize.test.js @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest' +import { serializeMarkdown } from '../../../src/renderer/src/utils/markdownSerialize' +import { parseMarkdown } from '../../../src/renderer/src/utils/markdownRender' + +const text = (value) => ({ type: 'text', value }) + +describe('serializeMarkdown', () => { + it('returns an empty string for an empty tree', () => { + expect(serializeMarkdown([])).toBe('') + expect(serializeMarkdown(null)).toBe('') + }) + + it('writes headings at their level', () => { + const blocks = [ + { type: 'heading', level: 1, inlines: [text('One')] }, + { type: 'heading', level: 6, inlines: [text('Six')] } + ] + expect(serializeMarkdown(blocks)).toBe('# One\n\n###### Six') + }) + + it('joins a paragraph on its own lines', () => { + const blocks = [{ type: 'paragraph', lines: [[text('first')], [text('second')]] }] + expect(serializeMarkdown(blocks)).toBe('first\nsecond') + }) + + it('writes every inline marker', () => { + const blocks = [ + { + type: 'paragraph', + lines: [ + [ + { type: 'strong', inlines: [text('b')] }, + text(' '), + { type: 'em', inlines: [text('i')] }, + text(' '), + { type: 'del', inlines: [text('s')] }, + text(' '), + { type: 'code', value: 'c' }, + text(' '), + { type: 'link', label: 'l', href: 'u' } + ] + ] + } + ] + expect(serializeMarkdown(blocks)).toBe('**b** *i* ~~s~~ `c` [l](u)') + }) + + // would be markup parseMarkdown cannot read back. + it('degrades ins to plain text', () => { + const blocks = [{ type: 'paragraph', lines: [[{ type: 'ins', inlines: [text('under')] }]] }] + expect(serializeMarkdown(blocks)).toBe('under') + }) + + it('indents nested list items two spaces per depth', () => { + const blocks = [ + { + type: 'list', + ordered: false, + items: [ + { depth: 1, inlines: [text('top')] }, + { depth: 2, inlines: [text('nested')] }, + { depth: 3, inlines: [text('deeper')] } + ] + } + ] + expect(serializeMarkdown(blocks)).toBe('- top\n - nested\n - deeper') + }) + + it('numbers ordered items', () => { + const blocks = [ + { + type: 'list', + ordered: true, + items: [ + { depth: 1, inlines: [text('a')] }, + { depth: 1, inlines: [text('b')] } + ] + } + ] + expect(serializeMarkdown(blocks)).toBe('1. a\n2. b') + }) + + it('writes task state', () => { + const blocks = [ + { + type: 'list', + ordered: false, + items: [ + { depth: 1, task: true, checked: false, inlines: [text('todo')] }, + { depth: 1, task: true, checked: true, inlines: [text('done')] } + ] + } + ] + expect(serializeMarkdown(blocks)).toBe('- [ ] todo\n- [x] done') + }) + + it('prefixes every line of a quote, including nested blocks', () => { + const blocks = [ + { + type: 'quote', + children: [ + { type: 'heading', level: 2, inlines: [text('Title')] }, + { type: 'paragraph', lines: [[text('body')]] } + ] + } + ] + expect(serializeMarkdown(blocks)).toBe('> ## Title\n>\n> body') + }) + + it('fences a code block', () => { + const blocks = [{ type: 'code', code: 'const a = 1\nconst b = 2' }] + expect(serializeMarkdown(blocks)).toBe('```\nconst a = 1\nconst b = 2\n```') + }) + + it('writes a table with its alignment row', () => { + const blocks = [ + { + type: 'table', + align: ['left', 'center', 'right'], + head: [[text('a')], [text('b')], [text('c')]], + rows: [[[text('1')], [text('2')], [text('3')]]] + } + ] + expect(serializeMarkdown(blocks)).toBe('| a | b | c |\n| :--- | :---: | ---: |\n| 1 | 2 | 3 |') + }) + + it('writes a default separator when no alignment is recorded', () => { + const blocks = [{ type: 'table', align: [], head: [[text('a')], [text('b')]], rows: [] }] + expect(serializeMarkdown(blocks)).toBe('| a | b |\n| --- | --- |') + }) + + it('separates blocks with a blank line', () => { + const blocks = [ + { type: 'heading', level: 1, inlines: [text('T')] }, + { type: 'paragraph', lines: [[text('p')]] }, + { type: 'code', code: 'x' } + ] + expect(serializeMarkdown(blocks)).toBe('# T\n\np\n\n```\nx\n```') + }) + + // The pair must survive its own output, or the loop drifts on every keystroke. + it('is stable when re-parsed and re-serialized', () => { + const src = [ + '# Title', + '', + 'Some **bold** and a [link](https://x.test).', + '', + '- one', + ' - nested', + '', + '1. first', + '2. second', + '', + '- [x] done', + '- [ ] todo', + '', + '> quoted', + '', + '```', + 'code()', + '```', + '', + '| a | b |', + '| --- | --- |', + '| 1 | 2 |' + ].join('\n') + const once = serializeMarkdown(parseMarkdown(src)) + expect(serializeMarkdown(parseMarkdown(once))).toBe(once) + }) +}) + +describe('serializeMarkdown — malformed trees', () => { + it('survives blocks missing their fields', () => { + expect( + serializeMarkdown([ + { type: 'heading' }, + { type: 'paragraph' }, + { type: 'list' }, + { type: 'code' }, + { type: 'quote' } + ]) + ).toBe('# \n\n\n\n\n\n```\n\n```\n\n>') + }) + + it('survives a block with no type at all', () => { + expect(serializeMarkdown([null])).toBe('') + }) + + it('treats an unknown block as a paragraph', () => { + expect(serializeMarkdown([{ type: 'mystery' }])).toBe('') + }) + + it('clamps a heading level into range', () => { + expect(serializeMarkdown([{ type: 'heading', level: 99, inlines: [] }])).toBe('###### ') + expect(serializeMarkdown([{ type: 'heading', level: 0, inlines: [] }])).toBe('# ') + }) + + it('survives inline nodes missing their fields', () => { + expect( + serializeMarkdown([ + { + type: 'paragraph', + lines: [[{ type: 'code' }, { type: 'link' }, { type: 'text' }, { type: 'mystery' }]] + } + ]) + ).toBe('``[]()') + }) + + it('defaults a list item with no depth to the first level', () => { + expect( + serializeMarkdown([{ type: 'list', items: [{ inlines: [{ type: 'text', value: 'x' }] }] }]) + ).toBe('- x') + }) + + it('sizes a table from its first row when there is no head', () => { + expect( + serializeMarkdown([ + { type: 'table', rows: [[[{ type: 'text', value: '1' }], [{ type: 'text', value: '2' }]]] } + ]) + ).toBe('| --- | --- |\n| 1 | 2 |') + }) +}) diff --git a/tests/renderer/utils/openWithRouting.test.js b/tests/renderer/utils/openWithRouting.test.js new file mode 100644 index 0000000..65786de --- /dev/null +++ b/tests/renderer/utils/openWithRouting.test.js @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { openWithTarget } from '../../../src/renderer/src/utils/openWithRouting' + +// Left, then right of that same tab, then a new tab. Derived from state, so an +// action between two opens cannot desync the cycle for good. +const tab = (over = {}) => ({ id: 't1', openWith: true, ...over }) + +describe('openWithTarget', () => { + it('opens the first file in a new tab, on the left', () => { + expect(openWithTarget({ active: null, hasLeft: false, hasRight: false })).toEqual({ + newTab: true, + side: 'left' + }) + }) + + it('puts the second file on the right of the same tab', () => { + expect(openWithTarget({ active: tab(), hasLeft: true, hasRight: false })).toEqual({ + newTab: false, + side: 'right' + }) + }) + + it('starts a new tab again for the third', () => { + expect(openWithTarget({ active: tab(), hasLeft: true, hasRight: true })).toEqual({ + newTab: true, + side: 'left' + }) + }) + + // Landing in a comparison the reader set up by hand would overwrite it. + it('will not fill the right of a tab it did not open', () => { + expect( + openWithTarget({ active: tab({ openWith: false }), hasLeft: true, hasRight: false }) + ).toEqual({ newTab: true, side: 'left' }) + }) + + it('starts a new tab when the previous one was closed', () => { + expect(openWithTarget({ active: null, hasLeft: true, hasRight: false })).toEqual({ + newTab: true, + side: 'left' + }) + }) + + // An open-with tab whose left the reader cleared is still theirs to fill. + it('fills the left of an empty tab it opened rather than making another', () => { + expect(openWithTarget({ active: tab(), hasLeft: false, hasRight: false })).toEqual({ + newTab: false, + side: 'left' + }) + }) + + it('repeats the cycle across four files', () => { + const seen = [] + let state = { active: null, hasLeft: false, hasRight: false } + for (let i = 0; i < 4; i++) { + const target = openWithTarget(state) + seen.push(`${target.newTab ? 'new' : 'same'}:${target.side}`) + const active = target.newTab ? tab({ id: `t${i}` }) : state.active + state = { + active, + hasLeft: target.newTab ? true : state.hasLeft || target.side === 'left', + hasRight: target.newTab ? false : target.side === 'right' + } + } + expect(seen).toEqual(['new:left', 'same:right', 'new:left', 'same:right']) + }) +}) diff --git a/tests/renderer/utils/serializeRoundTrip.test.js b/tests/renderer/utils/serializeRoundTrip.test.js new file mode 100644 index 0000000..2d2d46e --- /dev/null +++ b/tests/renderer/utils/serializeRoundTrip.test.js @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { parseMarkdown } from '../../../src/renderer/src/utils/markdownRender' +import { parseJira } from '../../../src/renderer/src/utils/jiraRender' +import { serializeMarkdown } from '../../../src/renderer/src/utils/markdownSerialize' +import { serializeJira } from '../../../src/renderer/src/utils/jiraSerialize' + +// domToBlocks.test.js owns the DOM leg; this owns the text legs. If the TREE +// survives a trip through text, an edit can lose spelling but never structure. +const DIALECTS = { + markdown: { parse: parseMarkdown, serialize: serializeMarkdown }, + jira: { parse: parseJira, serialize: serializeJira } +} + +const MARKDOWN_FIXTURES = [ + '# Heading', + 'Plain paragraph.', + 'Mixed **bold** and *em* and ~~gone~~ and `code`.', + 'A [link](https://x.test) mid-sentence.', + '- one\n- two', + '- top\n - nested\n - deeper', + '1. first\n2. second', + '- [ ] todo\n- [x] done', + '> quoted line', + '```\nfn()\n```', + '| a | b |\n| --- | --- |\n| 1 | 2 |', + '| a | b |\n| :--- | ---: |\n| 1 | 2 |', + '# Title\n\nBody text.\n\n- a\n- b' +] + +const JIRA_FIXTURES = [ + 'h1. Heading', + 'Plain paragraph.', + 'Mixed *bold* and _em_ and +ins+ and -del- and {{code}}.', + 'A [link|https://x.test] mid-sentence.', + '* one\n* two', + '* top\n** nested\n*** deeper', + '# first\n# second', + '{quote}\nquoted line\n{quote}', + '{code}\nfn()\n{code}', + '|| a || b ||\n| 1 | 2 |', + '| 1 | 2 |', + 'h1. Title\n\nBody text.\n\n* a\n* b' +] + +describe.each([ + ['markdown', MARKDOWN_FIXTURES], + ['jira', JIRA_FIXTURES] +])('%s round trip', (name, fixtures) => { + const { parse, serialize } = DIALECTS[name] + + it.each(fixtures)('keeps the tree through text: %j', (src) => { + expect(parse(serialize(parse(src)))).toEqual(parse(src)) + }) + + it.each(fixtures)('is a fixed point after one pass: %j', (src) => { + const once = serialize(parse(src)) + expect(serialize(parse(once))).toBe(once) + }) +}) + +// Asserted one by one, so a silent WIDENING of the losses fails the build. +describe('accepted Markdown normalisations', () => { + const normalised = (src) => serializeMarkdown(parseMarkdown(src)) + + it.each([ + ['* item', '- item'], + ['+ item', '- item'], + ['_em_', '*em*'], + ['__strong__', '**strong**'], + ['1) first', '1. first'], + ['~~~\ncode\n~~~', '```\ncode\n```'] + ])('%j becomes %j', (src, want) => { + expect(normalised(src)).toBe(want) + }) + + it('renumbers an ordered list from its position, not its source digits', () => { + expect(normalised('5. five\n9. nine')).toBe('1. five\n2. nine') + }) + + it('has no spelling for ins, so it degrades to text', () => { + expect(serializeMarkdown(parseJira('+underlined+'))).toBe('underlined') + }) +}) + +describe('accepted Jira normalisations', () => { + const normalised = (src) => serializeJira(parseJira(src)) + + it.each([ + ['bq. quoted', '{quote}\nquoted\n{quote}'], + ['{code:java}\nx\n{code}', '{code}\nx\n{code}'], + ['[https://x.test|https://x.test]', '[https://x.test]'] + ])('%j becomes %j', (src, want) => { + expect(normalised(src)).toBe(want) + }) + + it('drops a task box, which Jira has no syntax for', () => { + expect(serializeJira(parseMarkdown('- [x] done'))).toBe('* done') + }) +}) diff --git a/tests/renderer/utils/tabs.test.js b/tests/renderer/utils/tabs.test.js index 24b6d6c..b368701 100644 --- a/tests/renderer/utils/tabs.test.js +++ b/tests/renderer/utils/tabs.test.js @@ -190,6 +190,36 @@ describe('renaming', () => { expect(cleanTabName(' ')).toBe('') expect(cleanTabName(undefined)).toBe('') }) + + // A tab is a label, not a path or a pattern. `*/` was accepted and rendered, + // which reads as a broken tab rather than a named one. + it('keeps only word characters, spaces and hyphens', () => { + expect(cleanTabName('*/')).toBe('') + expect(cleanTabName('prod/staging')).toBe('prodstaging') + expect(cleanTabName('report: Q3 (final)')).toBe('report Q3 final') + expect(cleanTabName('')).toBe('scriptalert1script') + expect(cleanTabName('a*b?c|d')).toBe('abcd') + }) + + it('keeps the separators a real name uses', () => { + expect(cleanTabName('prod vs staging')).toBe('prod vs staging') + expect(cleanTabName('prod-vs-staging')).toBe('prod-vs-staging') + expect(cleanTabName('release_2026_08')).toBe('release_2026_08') + }) + + // The app ships 20 themes and a pseudolocale; stripping to ASCII \w would + // make a Lithuanian or Japanese tab name unnameable. + it('keeps letters outside ASCII', () => { + expect(cleanTabName('Ataskaita')).toBe('Ataskaita') + expect(cleanTabName('Résumé')).toBe('Résumé') + expect(cleanTabName('予算')).toBe('予算') + expect(cleanTabName('Кварtal')).toBe('Кварtal') + }) + + it('collapses the whitespace stripping leaves behind', () => { + expect(cleanTabName('a * b')).toBe('a b') + expect(cleanTabName(' */ ')).toBe('') + }) }) // The ceiling was a plain count standing in for a memory bound — every tab