Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md

Large diffs are not rendered by default.

42 changes: 40 additions & 2 deletions docs/brand/roadmap.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
71 changes: 71 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,77 @@ Done. Anchored coach marks over the real controls, split 6 + 4.

---

## OS integration

```mermaid
flowchart TD
finder["Finder / Explorer<br/>Open with"] --> ev{how it arrives}
ev -->|macOS| of["app.on('open-file')<br/>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,<br/>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
Expand Down
Binary file added docs/screenshots/themes/rendered-editor-amber.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/themes/rendered-editor-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/themes/rendered-editor-dim.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/themes/rendered-editor-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/themes/rendered-editor-nord.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/themes/rendered-editor-nyan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/themes/rendered-editor-tide.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 41 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (`<img onerror=…>`, `<iframe>`) landing in the DOM
behind Vue's back and then being serialized into the user's snippet. So the
handler calls `preventDefault()` and inserts only
`clipboardData.getData('text/plain')`. **`text/html` is never read** — do not
"improve" paste by honouring it.

Reading back (`domToBlocks`) touches only `nodeName`, `childNodes`,
`textContent` and a fixed class whitelist. Anything the whitelist does not name
degrades to its `textContent`, so an element that somehow reached the DOM can
only ever come back out as text — it can never be promoted into markup.

## Tools → Encrypt/Decrypt Text

The local passphrase text tool uses **authenticated AES-256-GCM only**
Expand Down
38 changes: 38 additions & 0 deletions e2e/collapsed-rail-toolbar.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { test, expect } from './fixtures.mjs'

// The key pair centres over the sidebar COLUMN, cancelling the toolbar's inset
// with a negative margin so it starts where the sidebar does. Collapsed, the
// pair is wider than the 47px rail, so centring cannot move it and that
// negative margin dragged it flush against the window edge.
const insetOf = (page) =>
page.evaluate(() => {
const btn = document.querySelector('.key-actions .btn')
const bar = document.querySelector('.app-toolbar') ?? btn.closest('header, .toolbar, div')
return {
buttonLeft: Math.round(btn.getBoundingClientRect().left),
barPad:
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--bar-pad')) || 12,
barLeft: Math.round(bar.getBoundingClientRect().left)
}
})

test('the key buttons keep the toolbar inset when the sidebar is collapsed', async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await expect(page.locator('.key-actions .btn').first()).toBeVisible()

await page.locator('.sidebar-toggle').first().click()
await page.locator('.rail').waitFor()
await page.waitForTimeout(300)

const { buttonLeft, barPad } = await insetOf(page)
// Never flush against the window: it keeps at least the toolbar's own inset.
expect(buttonLeft).toBeGreaterThanOrEqual(barPad)
})

test('the key buttons still centre over an expanded sidebar', async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await expect(page.locator('.key-actions .btn').first()).toBeVisible()
const { buttonLeft, barPad } = await insetOf(page)
// Expanded, the block centres over a 256px column, so it starts well inside.
expect(buttonLeft).toBeGreaterThan(barPad)
})
105 changes: 105 additions & 0 deletions e2e/open-with.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { spawn } from 'node:child_process'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { test, expect, launchApp, freshUserDataDir, firstReadyPage } from './fixtures.mjs'
import { workerEnv } from './workerEnv.mjs'

// An "Open with" is a SECOND launch carrying paths and no verb — the shape that
// used to exit(1) before a window existed. Only a real process proves it.
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const MAIN = join(ROOT, 'build', 'main', 'index.js')
const ELECTRON = createRequire(import.meta.url)('electron')

function openWith(userDataDir, files) {
const env = { ...process.env, ...workerEnv(userDataDir) }
delete env.ELECTRON_RUN_AS_NODE
return new Promise((resolve) => {
const p = spawn(ELECTRON, [MAIN, `--user-data-dir=${userDataDir}`, ...files], {
env,
stdio: 'ignore'
})
p.on('exit', resolve)
setTimeout(() => resolve(0), 8000)
})
}

const slotNames = (page) =>
page.locator('.file-slot .slot-name, .file-slots .name').allTextContents()

function fixtureFiles() {
const dir = mkdtempSync(join(tmpdir(), 'diffbro-openwith-'))
const write = (name, body) => {
const path = join(dir, name)
writeFileSync(path, body)
return path
}
return {
dir,
alpha: write('alpha.txt', 'one\ntwo\nthree\n'),
beta: write('beta.txt', 'one\ntwo CHANGED\nthree\n'),
gamma: write('gamma.txt', 'gamma\n')
}
}

test('a file opened from the OS fills the left pane, and a second joins it on the right', async () => {
const userDataDir = freshUserDataDir()
const files = fixtureFiles()
const app = await launchApp(userDataDir)
const page = await firstReadyPage(app)

await openWith(userDataDir, [files.alpha])
await expect(page.locator('.diff-tabs')).toBeVisible()
await expect(page.getByText('alpha.txt').first()).toBeVisible()

// The SECOND file joins the first rather than taking a tab of its own.
await openWith(userDataDir, [files.beta])
await expect(page.getByText('beta.txt').first()).toBeVisible()
expect(await page.locator('.diff-tabs .tab').count()).toBe(1)

// The third starts the cycle again.
await openWith(userDataDir, [files.gamma])
await expect.poll(async () => page.locator('.diff-tabs .tab').count(), { timeout: 8000 }).toBe(2)

await app.close()
rmSync(files.dir, { recursive: true, force: true })
rmSync(userDataDir, { recursive: true, force: true })
})

// Two files at once produced "Unknown command: <second path>" and exit(1),
// before any window existed — the app simply did not start.
test('two files selected at once open together instead of refusing to launch', async () => {
const userDataDir = freshUserDataDir()
const files = fixtureFiles()
const app = await launchApp(userDataDir)
const page = await firstReadyPage(app)

await openWith(userDataDir, [files.alpha, files.beta])

await expect(page.getByText('alpha.txt').first()).toBeVisible()
await expect(page.getByText('beta.txt').first()).toBeVisible()
// Both sides of ONE comparison.
expect(await page.locator('.diff-tabs .tab').count()).toBe(1)

await app.close()
rmSync(files.dir, { recursive: true, force: true })
rmSync(userDataDir, { recursive: true, force: true })
})

test('the app still starts when handed a path, rather than treating it as a command', async () => {
const userDataDir = freshUserDataDir()
const files = fixtureFiles()
const app = await launchApp(userDataDir)
const page = await firstReadyPage(app)

await openWith(userDataDir, [files.alpha, files.beta, files.gamma])
// Three files: two tabs, and the window is still alive to show them.
await expect.poll(async () => page.locator('.diff-tabs .tab').count(), { timeout: 8000 }).toBe(2)
expect(await slotNames(page)).toBeTruthy()

await app.close()
rmSync(files.dir, { recursive: true, force: true })
rmSync(userDataDir, { recursive: true, force: true })
})
Loading
Loading