diff --git a/e2e/copy-side-themes.spec.mjs b/e2e/copy-side-themes.spec.mjs
new file mode 100644
index 00000000..5ad9b188
--- /dev/null
+++ b/e2e/copy-side-themes.spec.mjs
@@ -0,0 +1,98 @@
+import { test, expect, openSettings, stubOpenDialog } from './fixtures.mjs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { THEMES } from '../src/renderer/src/utils/themes.js'
+
+// The slot's copy control is the one surface theme-sweep cannot reach: every
+// surface there is opened through paste mode, which replaces the slots row, and
+// the control only exists once a real FILE is loaded. So the all-20 check lives
+// here instead, measuring the same thing the sweep would — the computed colours
+// off the live DOM, composited against what is actually behind them.
+
+// It carries an icon, not prose: --text-dim at the mark floor, the same one
+// check-theme-depth holds dim ink to.
+const DIM = 3.0
+
+const contrastOf = (page, selector) =>
+ page.evaluate((sel) => {
+ const parse = (c) => (c.match(/[\d.]+/g) ?? []).map(Number)
+ const over = (fg, bg) => {
+ const a = fg.length > 3 ? fg[3] : 1
+ return [0, 1, 2].map((i) => fg[i] * a + bg[i] * (1 - a))
+ }
+ const lum = (rgb) => {
+ const [r, g, b] = rgb.map((v) => {
+ const s = v / 255
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
+ })
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+ }
+ // Walk up compositing every translucent layer, or a veil like --btn-face
+ // measures against nothing and reads as perfect contrast.
+ const groundOf = (el) => {
+ let acc = [255, 255, 255]
+ const chain = []
+ for (let n = el; n; n = n.parentElement) chain.unshift(n)
+ for (const n of chain) {
+ const bg = parse(getComputedStyle(n).backgroundColor)
+ if (bg.length >= 3 && (bg.length < 4 || bg[3] > 0)) acc = over(bg, acc)
+ }
+ return acc
+ }
+ const el = document.querySelector(sel)
+ if (!el) return null
+ const ground = groundOf(el.parentElement ?? el)
+ const surface = (() => {
+ const own = parse(getComputedStyle(el).backgroundColor)
+ return own.length >= 3 && (own.length < 4 || own[3] > 0) ? over(own, ground) : ground
+ })()
+ const ink = over(parse(getComputedStyle(el).color), surface)
+ const [a, b] = [lum(ink), lum(surface)].sort((x, y) => y - x)
+ return (a + 0.05) / (b + 0.05)
+ }, selector)
+
+test('the slot copy control is legible on every theme', async ({ app, page }) => {
+ const dir = mkdtempSync(join(tmpdir(), 'diffbro-copyside-themes-'))
+ const left = join(dir, 'alpha.txt')
+ const right = join(dir, 'beta.txt')
+ writeFileSync(left, 'one\ntwo\n')
+ writeFileSync(right, 'one\nTWO\n')
+
+ try {
+ await stubOpenDialog(app, left)
+ await page.locator('.slot[data-side="left"] .open').click()
+ await stubOpenDialog(app, right)
+ await page.locator('.slot[data-side="right"] .open').click()
+ await expect(page.locator('.slot[data-side="left"] .name')).toHaveText('alpha.txt')
+
+ const failures = []
+ const rows = []
+ for (const theme of THEMES) {
+ await openSettings(page)
+ const dlg = page.getByRole('dialog', { name: 'Settings' })
+ await dlg.getByRole('button', { name: `Use the ${theme.label} theme` }).click()
+ await page.keyboard.press('Escape')
+ await dlg.waitFor({ state: 'hidden' })
+ await expect(page.locator('html')).toHaveAttribute('data-theme', theme.id)
+
+ // Hovered is the only state it is ever seen in. .btn transitions colour
+ // over 120ms and getComputedStyle reports the INTERPOLATED value, so
+ // measuring straight after the hover reads a colour no one ever sees.
+ await page.locator('.slot[data-side="left"]').hover()
+ await page.waitForTimeout(250)
+ const ratio = await contrastOf(page, '.slot[data-side="left"] .copy')
+ // The file name beside it is --text on the same ground and is already held
+ // above the reading floor, so it calibrates the instrument: if this ever
+ // dips, the measurement is wrong before the control is.
+ const control = await contrastOf(page, '.slot[data-side="left"] .name')
+ rows.push(`${theme.id.padEnd(10)} copy ${ratio?.toFixed(2)} name ${control?.toFixed(2)}`)
+ if (!(control >= 4.5)) failures.push(`${theme.id}: INSTRUMENT name=${control?.toFixed(2)}`)
+ if (!(ratio >= DIM)) failures.push(`${theme.id}: ${ratio?.toFixed(2)}`)
+ }
+
+ expect(failures, `measured:\n${rows.join('\n')}\n`).toEqual([])
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
diff --git a/e2e/copy-side.spec.mjs b/e2e/copy-side.spec.mjs
new file mode 100644
index 00000000..1d3de5a6
--- /dev/null
+++ b/e2e/copy-side.spec.mjs
@@ -0,0 +1,154 @@
+import { test, expect, clickAppMenuItem, stubOpenDialog } from './fixtures.mjs'
+import { zipSync, strToU8 } from 'fflate'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+// Copying one SIDE puts that file's own text on the real OS clipboard, which is
+// the half a unit test cannot see: the write goes main-process (clipboard:write,
+// since navigator.clipboard is denied here) and the control only exists once the
+// slot has laid out. So drive it the way a reader does and read the clipboard
+// back through Electron.
+
+const LEFT_TEXT = 'one\ntwo\nthree\n'
+const RIGHT_TEXT = 'one\nTWO\nthree\n'
+
+function twoFiles() {
+ const dir = mkdtempSync(join(tmpdir(), 'diffbro-copyside-'))
+ const left = join(dir, 'alpha.txt')
+ const right = join(dir, 'beta.txt')
+ writeFileSync(left, LEFT_TEXT)
+ writeFileSync(right, RIGHT_TEXT)
+ return { dir, left, right }
+}
+
+async function loadPair(app, page, left, right) {
+ await stubOpenDialog(app, left)
+ await page.locator('.slot[data-side="left"] .open').click()
+ await stubOpenDialog(app, right)
+ await page.locator('.slot[data-side="right"] .open').click()
+ await expect(page.locator('.slot[data-side="right"] .name')).toHaveText('beta.txt')
+}
+
+const readClipboard = (app) => app.evaluate(({ clipboard }) => clipboard.readText())
+const clearClipboard = (app) => app.evaluate(({ clipboard }) => clipboard.clear())
+
+const copyButton = (page, side) => page.locator(`.slot[data-side="${side}"] .copy`)
+
+test('each slot copies its own side verbatim, not the diff between them', async ({ app, page }) => {
+ const { dir, left, right } = twoFiles()
+ try {
+ await loadPair(app, page, left, right)
+
+ await clearClipboard(app)
+ await copyButton(page, 'left').click()
+ await expect(page.locator('.notice')).toContainText('alpha.txt')
+ expect(await readClipboard(app)).toBe(LEFT_TEXT)
+
+ await clearClipboard(app)
+ await copyButton(page, 'right').click()
+ expect(await readClipboard(app)).toBe(RIGHT_TEXT)
+
+ // The give-away that this is the side and not Copy Diff as Patch, which
+ // shares the toolbar and would have written a ---/+++ header.
+ expect(await readClipboard(app)).not.toContain('---')
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+// The Edit menu (and so the palette and Cmd+Shift+1/2, which dispatch the same
+// two actions) reaches the same two sides. Driven through the menu item rather
+// than a keypress: a CDP-injected key never reaches a native accelerator, so
+// asserting on one would prove nothing about the binding.
+test('the Edit menu reaches the same two sides', async ({ app, page }) => {
+ const { dir, left, right } = twoFiles()
+ try {
+ await loadPair(app, page, left, right)
+
+ await clearClipboard(app)
+ await clickAppMenuItem(app, 'Copy Left Side')
+ await expect.poll(() => readClipboard(app)).toBe(LEFT_TEXT)
+
+ await clearClipboard(app)
+ await clickAppMenuItem(app, 'Copy Right Side')
+ await expect.poll(() => readClipboard(app)).toBe(RIGHT_TEXT)
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+// An opacity-0 button still takes clicks, so the thing that keeps it from
+// stealing one meant for the slot is that it sits BESIDE the name, never over
+// it. Assert the boxes are disjoint rather than trusting the flex row to stay
+// that way.
+test('the copy control stays out of the resting row and clear of the name', async ({
+ app,
+ page
+}) => {
+ const { dir, left, right } = twoFiles()
+ try {
+ await loadPair(app, page, left, right)
+ const copy = copyButton(page, 'left')
+
+ await expect(copy).toHaveCSS('opacity', '0')
+ await page.locator('.slot[data-side="left"]').hover()
+ await expect(copy).toHaveCSS('opacity', '1')
+
+ const open = await page.locator('.slot[data-side="left"] .open').boundingBox()
+ const box = await copy.boundingBox()
+ expect(box.x).toBeGreaterThanOrEqual(open.x + open.width - 1)
+
+ // Icon buttons come off the control scale, never padding + font-size.
+ const scale = await page.evaluate(() =>
+ getComputedStyle(document.documentElement).getPropertyValue('--control-h-sm').trim()
+ )
+ expect(Math.round(box.height)).toBe(parseInt(scale, 10))
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+// A spreadsheet side carries sheets and no text, so there is nothing to put on
+// the clipboard — it gets no control rather than one that refuses.
+test('a spreadsheet side offers no copy control at all', async ({ app, page }) => {
+ const XML = ''
+ const cell = (ref, text) => `${text}`
+ const book = (value) =>
+ Buffer.from(
+ zipSync({
+ 'xl/workbook.xml': strToU8(
+ `${XML}` +
+ ''
+ ),
+ 'xl/_rels/workbook.xml.rels': strToU8(
+ `${XML}'
+ ),
+ 'xl/worksheets/sheet1.xml': strToU8(
+ `${XML}` +
+ `${cell('A1', 'Region')}${cell('B1', value)}`
+ )
+ })
+ )
+
+ const dir = mkdtempSync(join(tmpdir(), 'diffbro-copyside-xlsx-'))
+ const left = join(dir, 'a.xlsx')
+ const right = join(dir, 'b.xlsx')
+ writeFileSync(left, book('100'))
+ writeFileSync(right, book('150'))
+ try {
+ await stubOpenDialog(app, left)
+ await page.locator('.slot[data-side="left"] .open').click()
+ await stubOpenDialog(app, right)
+ await page.locator('.slot[data-side="right"] .open').click()
+ await expect(page.locator('.slot[data-side="right"] .name')).toHaveText('b.xlsx')
+
+ await page.locator('.slot[data-side="left"]').hover()
+ await expect(copyButton(page, 'left')).toHaveCount(0)
+ await expect(copyButton(page, 'right')).toHaveCount(0)
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
diff --git a/e2e/deep-html.spec.mjs b/e2e/deep-html.spec.mjs
new file mode 100644
index 00000000..f45b26ce
--- /dev/null
+++ b/e2e/deep-html.spec.mjs
@@ -0,0 +1,42 @@
+import { test, expect, stubOpenDialog } from './fixtures.mjs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+// Monaco's HTML worker walks the document tree to build symbols with a plain
+// recursion and no depth guard (provideFileSymbolsInternal → children.forEach →
+// itself). An unclosed tag nests everything after it, so a generated or
+// truncated HTML file recurses once per line and overflows the stack — which
+// reaches the reader as a crash-report dialog over a file the app should simply
+// have shown. Only a real launch runs the worker at all, so this lives here.
+
+// Two frames per level, against a ~10k frame budget.
+const DEPTH = 9000
+
+const nested = (marker) =>
+ `
\n${'
\n'.repeat(DEPTH)}${marker}\n${'
\n'.repeat(DEPTH)}\n`
+
+test('a deeply nested HTML file compares without overflowing the worker', async ({ app, page }) => {
+ const dir = mkdtempSync(join(tmpdir(), 'diffbro-deep-html-'))
+ const left = join(dir, 'left.html')
+ const right = join(dir, 'right.html')
+ writeFileSync(left, nested('ALPHA'))
+ writeFileSync(right, nested('OMEGA'))
+
+ try {
+ await stubOpenDialog(app, left)
+ await page.locator('.slot[data-side="left"] .open').click()
+ await stubOpenDialog(app, right)
+ await page.locator('.slot[data-side="right"] .open').click()
+
+ // The diff itself must land: one changed line, deep inside the nesting.
+ await expect(page.locator('.status-band .del')).toContainText('1 removed', { timeout: 30_000 })
+
+ // The symbol walk is kicked off after the model settles, so give it the
+ // chance to blow up before declaring the file safe to open.
+ await page.waitForTimeout(2000)
+ await expect(page.getByRole('dialog', { name: 'Something went wrong' })).toHaveCount(0)
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
diff --git a/scripts/lib/legacySize.mjs b/scripts/lib/legacySize.mjs
index bcf51c6f..893da7fb 100644
--- a/scripts/lib/legacySize.mjs
+++ b/scripts/lib/legacySize.mjs
@@ -22,9 +22,9 @@ export const LEGACY_SIZE = {
'src/renderer/src/composables/useStreamedDiff.js': { fn: 99 },
'src/renderer/src/composables/useTabContextMenu.js': { fn: 70 },
'src/renderer/src/composables/useTagInput.js': { fn: 84 },
- 'src/renderer/src/menus.js': { fn: 87 },
+ 'src/renderer/src/menus.js': { fn: 67 },
'src/renderer/src/monaco-mermaid.js': { fn: 94 },
- 'src/renderer/src/stores/diffStore.js': { file: 747 },
+ 'src/renderer/src/stores/diffStore.js': { file: 710 },
'src/renderer/src/stores/snippetStore.js': { file: 448 },
'src/renderer/src/stores/tabsStore.js': { file: 323 },
'src/renderer/src/stores/vaultStore.js': { file: 362 },
diff --git a/src/main/menuSections.js b/src/main/menuSections.js
index 20975bb0..81f2abe2 100644
--- a/src/main/menuSections.js
+++ b/src/main/menuSections.js
@@ -41,6 +41,17 @@ export const editMenu = (send, isMac) => ({
accelerator: 'CmdOrCtrl+Shift+F',
click: () => send('copy-diff-file')
},
+ // Shift over the digit that OPENS that side (CmdOrCtrl+1 / +2).
+ {
+ label: t('menu.edit.copyLeft'),
+ accelerator: 'CmdOrCtrl+Shift+1',
+ click: () => send('copy-left')
+ },
+ {
+ label: t('menu.edit.copyRight'),
+ accelerator: 'CmdOrCtrl+Shift+2',
+ click: () => send('copy-right')
+ },
{ label: t('menu.edit.applyPatch'), click: () => send('apply-patch') },
{ type: 'separator' },
{
diff --git a/src/renderer/src/adapters/textAdapter.js b/src/renderer/src/adapters/textAdapter.js
index 0216e7ef..82162365 100644
--- a/src/renderer/src/adapters/textAdapter.js
+++ b/src/renderer/src/adapters/textAdapter.js
@@ -13,6 +13,12 @@ const EXT_TO_LANGUAGE = {
tsx: 'typescript',
jsx: 'javascript',
json: 'json',
+ // JSON by spec, and all three routinely carry other languages inside their
+ // string values — a HAR holds whole captured HTML responses. Naming them here
+ // keeps that payload from deciding what the file is.
+ har: 'json',
+ map: 'json',
+ webmanifest: 'json',
css: 'css',
scss: 'scss',
html: 'html',
diff --git a/src/renderer/src/components/FileSlot.vue b/src/renderer/src/components/FileSlot.vue
index bb966d4d..9c9442bd 100644
--- a/src/renderer/src/components/FileSlot.vue
+++ b/src/renderer/src/components/FileSlot.vue
@@ -1,6 +1,10 @@
-
+
+
+
+
diff --git a/src/renderer/src/components/FileSlotsRow.vue b/src/renderer/src/components/FileSlotsRow.vue
index a473c342..7654f4c7 100644
--- a/src/renderer/src/components/FileSlotsRow.vue
+++ b/src/renderer/src/components/FileSlotsRow.vue
@@ -16,6 +16,7 @@ const store = useDiffStore()
:file="store.left"
:awaiting="!store.left && !!store.right"
@pick="store.pick('left')"
+ @copy="store.copySide('left')"
/>
diff --git a/src/renderer/src/components/styles/FileSlot.css b/src/renderer/src/components/styles/FileSlot.css
index fe09cc69..91ba1ba1 100644
--- a/src/renderer/src/components/styles/FileSlot.css
+++ b/src/renderer/src/components/styles/FileSlot.css
@@ -1,19 +1,37 @@
+/* The slot is the field; `.open` is the click target that fills it and `.copy`
+ rides at its trailing edge. Height comes from the control scale rather than
+ padding + line-height, so adding the button cannot drift the row. */
.slot {
flex: 1;
min-width: 0;
- text-align: left;
+ display: flex;
+ align-items: center;
+ height: var(--control-h);
background: var(--bg);
border: 1px dashed var(--border);
border-radius: var(--radius);
color: var(--text-dim);
- padding: 6px 10px;
- cursor: pointer;
+ padding: 0 4px 0 0;
overflow: hidden;
}
.slot.filled {
border-style: solid;
color: var(--text);
}
+.open {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ text-align: left;
+ padding: 0 10px;
+ background: none;
+ border: 0;
+ border-radius: inherit;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+ overflow: hidden;
+}
/* `.hover` is the DRAG-over state (set from JS). This is the pointer one: the
slot is a button that opens a file dialog, and at rest it reads as a field.
Same three cues the .btn ladder uses — face, keyline, lift — so it says
@@ -23,7 +41,7 @@
border-color: var(--btn-edge);
box-shadow: var(--shadow-1);
}
-.slot:active:not(.hover) {
+.slot:has(.open:active):not(.hover) {
background: var(--btn-face-press);
box-shadow: none;
}
@@ -58,3 +76,32 @@
.placeholder {
text-transform: capitalize;
}
+/* Secondary to the slot's own job, so it stays out of the resting row and
+ arrives on hover. It sits BESIDE the name rather than over it, so a pointer
+ can only be on it while the slot is hovered and it is already visible — no
+ invisible target ever takes a click meant for the slot, and suppressing its
+ pointer events would only make it unhittable. Tab reaches it either way, and
+ focus reveals it. */
+.copy {
+ flex: none;
+ opacity: 0;
+}
+/* Full ink, not .btn-icon's --text-dim: revealed is the only state this is ever
+ SEEN in, and dim over the hovered slot's face falls under the 3:1 mark floor
+ on five light themes — sepia 2.09, solar 2.37, bloom 2.58, linen 2.72,
+ meridian 2.91. Measured in e2e/copy-side-themes.spec.mjs. */
+.slot:hover .copy,
+.copy:focus-visible {
+ opacity: 1;
+ color: var(--text);
+}
+.copy:hover {
+ border-color: var(--btn-edge);
+ background: var(--btn-face-hover);
+ color: var(--text);
+}
+@media (prefers-reduced-motion: reduce) {
+ .slot.awaiting {
+ animation: none;
+ }
+}
diff --git a/src/renderer/src/composables/useSnippetRowActions.js b/src/renderer/src/composables/useSnippetRowActions.js
index da334e04..b00b495e 100644
--- a/src/renderer/src/composables/useSnippetRowActions.js
+++ b/src/renderer/src/composables/useSnippetRowActions.js
@@ -3,7 +3,7 @@ import { useSnippetStore, languageOf } from '../stores/snippetStore'
import { useDiffStore } from '../stores/diffStore'
import { useUiStore } from '../stores/uiStore'
import { useCopyFeedback } from './useCopyFeedback'
-import { firstClaudeUrl } from '../utils/detectLanguage'
+import { firstClaudeUrl } from '../utils/claudeLink'
import { parseTemplateVars } from '../utils/templateVars'
import { t } from '../i18n'
diff --git a/src/renderer/src/menus.js b/src/renderer/src/menus.js
index 490c68b6..7d28b325 100644
--- a/src/renderer/src/menus.js
+++ b/src/renderer/src/menus.js
@@ -89,6 +89,23 @@ const viewSection = (run) => ({
]
})
+const editSection = (run) => ({
+ id: 'edit',
+ label: t('menu.edit.title'),
+ items: [
+ { label: t('menu.edit.swapSides'), keys: `${MOD}+Shift+S`, run: () => run('swap') },
+ { label: t('menu.edit.clear'), keys: `${MOD}+K`, run: () => run('clear') },
+ { label: t('menu.edit.copyPatch'), keys: `${MOD}+Shift+C`, run: () => run('copy-diff') },
+ { label: t('menu.edit.copyFile'), keys: `${MOD}+Shift+F`, run: () => run('copy-diff-file') },
+ // Shift over the digit that OPENS that side (MOD+1 / MOD+2).
+ { label: t('menu.edit.copyLeft'), keys: `${MOD}+Shift+1`, run: () => run('copy-left') },
+ { label: t('menu.edit.copyRight'), keys: `${MOD}+Shift+2`, run: () => run('copy-right') },
+ { label: t('menu.edit.applyPatch'), run: () => run('apply-patch') },
+ { sep: true },
+ { label: t('menu.edit.pasteTextMode'), keys: `${MOD}+T`, run: () => run('toggle-paste') }
+ ]
+})
+
export function buildMenus(run) {
return [
{
@@ -131,27 +148,7 @@ export function buildMenus(run) {
{ label: t('menu.file.quit'), paletteHidden: true, run: () => window.api.quit() }
]
},
- {
- id: 'edit',
- label: t('menu.edit.title'),
- items: [
- { label: t('menu.edit.swapSides'), keys: `${MOD}+Shift+S`, run: () => run('swap') },
- { label: t('menu.edit.clear'), keys: `${MOD}+K`, run: () => run('clear') },
- {
- label: t('menu.edit.copyPatch'),
- keys: `${MOD}+Shift+C`,
- run: () => run('copy-diff')
- },
- {
- label: t('menu.edit.copyFile'),
- keys: `${MOD}+Shift+F`,
- run: () => run('copy-diff-file')
- },
- { label: t('menu.edit.applyPatch'), run: () => run('apply-patch') },
- { sep: true },
- { label: t('menu.edit.pasteTextMode'), keys: `${MOD}+T`, run: () => run('toggle-paste') }
- ]
- },
+ editSection(run),
viewSection(run),
{ id: 'terminal', label: t('menu.terminal.title'), items: terminalItems(run) },
{ id: 'security', label: t('menu.security.title'), items: securityItems(run) },
diff --git a/src/renderer/src/monaco-setup.js b/src/renderer/src/monaco-setup.js
index 9d8766ee..6d7d2eac 100644
--- a/src/renderer/src/monaco-setup.js
+++ b/src/renderer/src/monaco-setup.js
@@ -29,6 +29,19 @@ monaco.editor.addKeybindingRules?.([
{ keybinding: monaco.KeyMod.Shift | monaco.KeyCode.F7, command: null }
])
+// Monaco's HTML worker builds document symbols by plain recursion with no depth
+// guard (provideFileSymbolsInternal → children.forEach → itself), so an unclosed
+// tag — which nests everything after it — overflows the stack and reaches the
+// reader as a crash report over a file we should just have shown. Nothing here
+// consumes symbols: there is no outline, no breadcrumbs, and the palette that
+// would offer Go to Symbol is unbound above. setModeConfiguration REPLACES, so
+// the rest of the features have to be carried across.
+const htmlDefaults = monaco.languages.html?.htmlDefaults
+htmlDefaults?.setModeConfiguration({
+ ...htmlDefaults.modeConfiguration,
+ documentSymbols: false
+})
+
self.MonacoEnvironment = {
getWorker(_, label) {
if (label === 'json') return new jsonWorker()
diff --git a/src/renderer/src/stores/diffCopy.js b/src/renderer/src/stores/diffCopy.js
new file mode 100644
index 00000000..fbf878ad
--- /dev/null
+++ b/src/renderer/src/stores/diffCopy.js
@@ -0,0 +1,53 @@
+import { diffPatchFile } from '../utils/copyAsFile'
+import { copyableSide } from '../utils/sideText'
+import { STREAMED_LIMITS } from '../utils/streamedLimits'
+import { t } from '../i18n'
+
+// Everything that puts the comparison on the clipboard. Its own module because
+// the store is at its size cap. Clipboard writes go through main —
+// navigator.clipboard is denied here by the permission handler.
+
+// The builder returns a sentinel for the streamed case so it need not carry the
+// store's own wording for a limit the store already owns.
+const patchError = (reason) => (reason === 'streamed' ? t(STREAMED_LIMITS.copy) : reason)
+
+export const copyActions = {
+ // Recompute a clean git-style patch (Monaco's on-screen diff isn't one).
+ async copyDiff() {
+ const file = diffPatchFile(this)
+ if (file.error) return this.showNotice(patchError(file.error))
+ const out = await window.api.copyText(file.content)
+ this.showNotice(
+ out?.ok ? t('diffNotices.unifiedDiffCopiedToClipboard') : t('diffNotices.couldNotCopyTheDiff')
+ )
+ },
+ // The twin: a real .patch file on the clipboard, for a destination that
+ // wants a file rather than characters.
+ async copyDiffAsFile() {
+ const file = diffPatchFile(this)
+ if (file.error) return this.showNotice(patchError(file.error))
+ const out = await window.api.copyAsFile(file.name, file.content)
+ this.showNotice(
+ out?.ok
+ ? t('diffNotices.copiedAsFile', { name: out.name })
+ : t('diffNotices.couldNotCopyThatAs')
+ )
+ },
+ /**
+ * One side, verbatim — not the patch. Declines silently when that side has no
+ * text to give: the slot hides its copy control in the same case, so reaching
+ * here means a shortcut or menu fired against a spreadsheet or a streamed
+ * file, and a notice about an action nothing offered would be noise.
+ * @param {'left'|'right'} side
+ */
+ async copySide(side) {
+ const file = copyableSide(this, side)
+ if (!file) return
+ const out = await window.api.copyText(file.content)
+ this.showNotice(
+ out?.ok
+ ? t('diffNotices.sideCopied', { name: file.name })
+ : t('diffNotices.couldNotCopyTheSide')
+ )
+ }
+}
diff --git a/src/renderer/src/stores/diffStore.js b/src/renderer/src/stores/diffStore.js
index 44acc830..b9b9a23d 100644
--- a/src/renderer/src/stores/diffStore.js
+++ b/src/renderer/src/stores/diffStore.js
@@ -12,7 +12,8 @@ import { isSecret } from '../utils/secretSnippet'
import { snippetSource } from '../utils/snippetSource'
import { detectTextFormat, formatJson, formatXml } from '../utils/textFormats'
import { applyUnifiedDiff } from '../utils/unifiedDiff'
-import { diffPatchFile } from '../utils/copyAsFile'
+import { comparedSides } from '../utils/sideText'
+import { copyActions } from './diffCopy'
import { diffToHtml } from '../utils/diffHtml'
import { changeRegister, toCsv } from '../utils/changeRegister'
import { clipboardSnippetName } from '../utils/cliCommand'
@@ -23,10 +24,6 @@ import { STREAMED_LIMITS } from '../utils/streamedLimits'
import { mergeFormatBanner } from '../utils/formatBanner'
import { t } from '../i18n'
-// The builder returns a sentinel for the streamed case so it need not carry the
-// store's own wording for a limit the store already owns.
-const patchError = (reason) => (reason === 'streamed' ? t(STREAMED_LIMITS.copy) : reason)
-
let noticeTimer = null
let diskNoticeTimer = null
@@ -41,17 +38,6 @@ function formatHintFor(file, dismissedContent) {
return { kind: detected.kind, valid: true }
}
-// The two compared sides as { name, content }, whether in files or paste mode.
-function comparedSides(s) {
- if (s.mode === 'paste') {
- return [
- s.pasteLeftFile ?? { name: 'Left', content: s.pasteLeft },
- s.pasteRightFile ?? { name: 'Right', content: s.pasteRight }
- ]
- }
- return [s.left ?? { name: 'Left', content: '' }, s.right ?? { name: 'Right', content: '' }]
-}
-
const reloadedNote = (names) => {
if (!names.length) return ''
if (names.length === 1) return `"${names[0]}" changed on disk — diff reloaded.`
@@ -587,30 +573,7 @@ export const useDiffStore = defineStore('diff', {
clearTimeout(diskNoticeTimer)
this.diskNotice = null
},
- // Recompute a clean git-style patch (Monaco's on-screen diff isn't one).
- // Clipboard goes through main — navigator.clipboard is denied here.
- async copyDiff() {
- const file = diffPatchFile(this)
- if (file.error) return this.showNotice(patchError(file.error))
- const out = await window.api.copyText(file.content)
- this.showNotice(
- out?.ok
- ? t('diffNotices.unifiedDiffCopiedToClipboard')
- : t('diffNotices.couldNotCopyTheDiff')
- )
- },
- // The twin: a real .patch file on the clipboard, for a destination that
- // wants a file rather than characters.
- async copyDiffAsFile() {
- const file = diffPatchFile(this)
- if (file.error) return this.showNotice(patchError(file.error))
- const out = await window.api.copyAsFile(file.name, file.content)
- this.showNotice(
- out?.ok
- ? t('diffNotices.copiedAsFile', { name: out.name })
- : t('diffNotices.couldNotCopyThatAs')
- )
- },
+ ...copyActions,
swap() {
;[this.left, this.right] = [this.right, this.left]
// A swapped comparison no longer matches the saved snapshot's side order.
diff --git a/src/renderer/src/utils/claudeLink.js b/src/renderer/src/utils/claudeLink.js
new file mode 100644
index 00000000..76e13f18
--- /dev/null
+++ b/src/renderer/src/utils/claudeLink.js
@@ -0,0 +1,13 @@
+// Recognising a claude.ai link in snippet text. Only the categorisation happens
+// here; opening is gated by the main-process allowlist (src/main/links.js) and
+// never by these loose matches.
+
+const CLAUDE_LINK_RE = /^https:\/\/(www\.)?claude\.ai\/\S*$/i
+
+/** Whether the text is nothing but a claude.ai URL — a stored artifact or chat. */
+export const isClaudeLink = (t) => !t.includes('\n') && CLAUDE_LINK_RE.test(t)
+
+const CLAUDE_URL_G = /https:\/\/(?:www\.)?claude\.ai\/\S*/i
+
+/** First claude.ai URL embedded anywhere, for the row's "Open link" action. */
+export const firstClaudeUrl = (t) => String(t ?? '').match(CLAUDE_URL_G)?.[0] ?? null
diff --git a/src/renderer/src/utils/commands.js b/src/renderer/src/utils/commands.js
index 97cb56d4..86f8cbf2 100644
--- a/src/renderer/src/utils/commands.js
+++ b/src/renderer/src/utils/commands.js
@@ -37,6 +37,11 @@ export const COMMANDS = {
},
'copy-diff': ({ diff }) => diff.copyDiff(),
'copy-diff-file': ({ diff }) => diff.copyDiffAsFile(),
+ // One side verbatim, rather than the patch between them. The store declines
+ // when that side has no text, which is the same answer the slot gives by
+ // hiding its copy control — so no surface can offer what another refuses.
+ 'copy-left': ({ diff }) => diff.copySide('left'),
+ 'copy-right': ({ diff }) => diff.copySide('right'),
'apply-patch': ({ diff }) => diff.applyPatch(),
'export-html': ({ diff }) => diff.exportDiff(),
'export-image': ({ imageExport }) => imageExport.exportCurrentImage(),
diff --git a/src/renderer/src/utils/detectLanguage.js b/src/renderer/src/utils/detectLanguage.js
index 78205f22..afd9aa84 100644
--- a/src/renderer/src/utils/detectLanguage.js
+++ b/src/renderer/src/utils/detectLanguage.js
@@ -3,6 +3,7 @@
// miss only lands on plaintext), ordered most-distinctive-first, and bounded —
// the content can come from a snippet somebody else sealed.
import { validateJson } from './textFormats'
+import { isClaudeLink } from './claudeLink'
import { looksLikeMermaid } from './mermaid'
// The picker's options live in src/shared so the CLI prompt offers the same set.
@@ -172,11 +173,13 @@ function looksLikeShell(t) {
}
// Ordered most-distinctive-first; the first detector to claim the text wins.
+// The two that scan anywhere skip JSON-shaped text, which isJson misses once a
+// file is too big to parse inside the window — else its quoted markup names it.
const DETECTORS = [
detectShebang,
- (t) => (PHP_RE.test(t) ? 'php' : null),
+ (t) => (!jsonShaped(t) && PHP_RE.test(t) ? 'php' : null),
(t) => (XML_DECL_RE.test(t) ? 'xml' : null),
- (t) => (HTML_RE.test(t) ? 'html' : null),
+ (t) => (!jsonShaped(t) && HTML_RE.test(t) ? 'html' : null),
(t) => (looksLikeXml(t) ? 'xml' : null),
(t) => (looksLikeDockerfile(t) ? 'dockerfile' : null),
(t) => (looksLikeCss(t) ? 'css' : null),
@@ -198,19 +201,8 @@ const MARKDOWN_FENCE = /^```/m
// After the code detectors, so a lone `#` can't pre-empt a real program.
const MARKDOWN_STRONG = [/^#{1,6}\s+\S/m, /\[[^\]]+\]\([^)]+\)/]
const MARKDOWN_WEAK = [/^[-*+]\s+\S/m, /^\d+\.\s+\S/m, /^>\s+\S/m]
-
-const isJson = (t) => (t[0] === '{' || t[0] === '[') && validateJson(t).valid
-
-// A snippet that is just a claude.ai URL is a stored artifact/chat link. Only
-// the categorisation is done here; opening is gated by the main-process
-// allowlist (src/main/links.js), never by this loose match.
-const CLAUDE_LINK_RE = /^https:\/\/(www\.)?claude\.ai\/\S*$/i
-const isClaudeLink = (t) => !t.includes('\n') && CLAUDE_LINK_RE.test(t)
-
-// First claude.ai URL embedded anywhere in the text — the candidate the row's
-// "Open link" action hands to main, which re-validates it against the allowlist.
-const CLAUDE_URL_G = /https:\/\/(?:www\.)?claude\.ai\/\S*/i
-export const firstClaudeUrl = (t) => String(t ?? '').match(CLAUDE_URL_G)?.[0] ?? null
+const jsonShaped = (t) => t[0] === '{' || t[0] === '['
+const isJson = (t) => jsonShaped(t) && validateJson(t).valid
// A heading or link alone is enough; the weaker list/quote signals (also common
// in plain prose) must appear at least twice together.
diff --git a/src/renderer/src/utils/shortcuts.js b/src/renderer/src/utils/shortcuts.js
index 4ef59e0b..10d221aa 100644
--- a/src/renderer/src/utils/shortcuts.js
+++ b/src/renderer/src/utils/shortcuts.js
@@ -41,6 +41,8 @@ export const SHORTCUT_GROUPS = [
{ keys: `${MOD}+K`, labelKey: 'shortcuts.clear' },
{ keys: `${MOD}+Shift+C`, labelKey: 'shortcuts.copyPatch' },
{ keys: `${MOD}+Shift+F`, labelKey: 'shortcuts.copyFile' },
+ { keys: `${MOD}+Shift+1`, labelKey: 'shortcuts.copyLeft' },
+ { keys: `${MOD}+Shift+2`, labelKey: 'shortcuts.copyRight' },
{ keys: `${MOD}+T`, labelKey: 'shortcuts.pasteTextMode' },
{ keys: `${MOD}+V`, labelKey: 'shortcuts.pasteToCompare' }
]
diff --git a/src/renderer/src/utils/sideText.js b/src/renderer/src/utils/sideText.js
new file mode 100644
index 00000000..7e8d73b0
--- /dev/null
+++ b/src/renderer/src/utils/sideText.js
@@ -0,0 +1,44 @@
+// The text of each compared side, and whether a side has any to give.
+//
+// Two callers with one answer between them: the slot decides whether to offer a
+// copy control at all, and the copy action reads what to put on the clipboard.
+// Splitting that decision would let a button appear over a side that copies
+// nothing.
+
+/**
+ * The two compared sides as { name, content }, whether in files or paste mode.
+ * @param {object} store the diff store
+ * @returns {[{ name: string, content?: string }, { name: string, content?: string }]}
+ */
+export function comparedSides(store) {
+ if (store.mode === 'paste') {
+ return [
+ store.pasteLeftFile ?? { name: 'Left', content: store.pasteLeft },
+ store.pasteRightFile ?? { name: 'Right', content: store.pasteRight }
+ ]
+ }
+ return [
+ store.left ?? { name: 'Left', content: '' },
+ store.right ?? { name: 'Right', content: '' }
+ ]
+}
+
+/**
+ * Whether this side holds text a reader could copy. A spreadsheet carries
+ * `sheets` and a streamed file carries only a path, so neither has `content` —
+ * both answer false, which is what keeps the copy control off them.
+ * @param {{ content?: string }|null} file
+ */
+export const isCopyableSide = (file) => typeof file?.content === 'string' && file.content.length > 0
+
+/**
+ * The side to copy, or null when there is nothing to put on the clipboard.
+ * @param {object} store the diff store
+ * @param {'left'|'right'} side
+ * @returns {{ name: string, content: string }|null}
+ */
+export function copyableSide(store, side) {
+ const [left, right] = comparedSides(store)
+ const file = side === 'right' ? right : left
+ return isCopyableSide(file) ? { name: file.name, content: file.content } : null
+}
diff --git a/src/shared/i18n/en-XA.json b/src/shared/i18n/en-XA.json
index 6165123a..e0946f2e 100644
--- a/src/shared/i18n/en-XA.json
+++ b/src/shared/i18n/en-XA.json
@@ -79,7 +79,9 @@
"copyPatch": "[Çōƥŷ Đĩƒƒ àş Ƥàţçĥ ·øé·øé]",
"copyFile": "[Çōƥŷ Đĩƒƒ àş Ƒĩłé ·øé·øé]",
"applyPatch": "[Àƥƥłŷ Ƥàţçĥ… ·øé·]",
- "pasteTextMode": "[Ƥàşţé Ţéẋţ Ṁōđé ·øé·øé]"
+ "pasteTextMode": "[Ƥàşţé Ţéẋţ Ṁōđé ·øé·øé]",
+ "copyLeft": "[Çōƥŷ Łéƒţ Šĩđé ·øé·ø]",
+ "copyRight": "[Çōƥŷ Řĩğĥţ Šĩđé ·øé·øé]"
},
"view": {
"title": "[Ṽĩéŵ ·ø]",
@@ -241,7 +243,9 @@
"splitView": "[şƥłĩţ ṽĩéŵ ·øé·]",
"clear": "[çłéàř ·ø]"
},
- "quickLookAppWide": "[Ɋūĩçķ łōōķ-ūƥ (ŵōřķş àƥƥ-ŵĩđé) ·øé·øé·øé·]"
+ "quickLookAppWide": "[Ɋūĩçķ łōōķ-ūƥ (ŵōřķş àƥƥ-ŵĩđé) ·øé·øé·øé·]",
+ "copyLeft": "[Çōƥŷ łéƒţ şĩđé ·øé·ø]",
+ "copyRight": "[Çōƥŷ řĩğĥţ şĩđé ·øé·øé]"
},
"shortcutBar": {
"hideTip": "[Ĥĩđé ţĥĩş ƀàř — ƀřĩńğ ĩţ ƀàçķ ĩń Šéţţĩńğş ·øé·øé·øé·øé·]",
@@ -1452,7 +1456,9 @@
"copiedAsFile": "[{name} çōƥĩéđ àş à ƒĩłé — ƥàşţé ĩţ ŵĥéřé ŷōū ńééđ ĩţ. ·øé·øé·øé·øé·øé·]",
"bothSidesLookLike": "[Ɓōţĥ şĩđéş łōōķ łĩķé {kind} — ƥřéţţŷ-ƥřĩńţ? ·øé·øé·øé·øé·]",
"savedExpiresIn": "[Šàṽéđ — éẋƥĩřéş ĩń {hours} ĥ. ·øé·øé·ø]",
- "importedSnippets": "[Ĩɱƥōřţéđ ōńé şńĩƥƥéţ. | Ĩɱƥōřţéđ {n} şńĩƥƥéţş. ·øé·øé·øé·øé·ø]"
+ "importedSnippets": "[Ĩɱƥōřţéđ ōńé şńĩƥƥéţ. | Ĩɱƥōřţéđ {n} şńĩƥƥéţş. ·øé·øé·øé·øé·ø]",
+ "sideCopied": "[Çōƥĩéđ {name} ·øé·]",
+ "couldNotCopyTheSide": "[Çōūłđ ńōţ çōƥŷ ţĥàţ şĩđé. ·øé·øé·ø]"
},
"errorNotices": {
"unknownError": "[Ūńķńōŵń éřřōř ·øé·ø]"
@@ -1612,7 +1618,8 @@
"hiddenByFilter": "[\"{name}\" ŵàş àđđéđ, ƀūţ ţĥé çūřřéńţ ƒĩłţéř ĥĩđéş ĩţ. ·øé·øé·øé·øé·øé·]"
},
"fileSlot": {
- "chooseSideFile": "[Çĥōōşé {side} ƒĩłé ·øé·øé]"
+ "chooseSideFile": "[Çĥōōşé {side} ƒĩłé ·øé·øé]",
+ "copySide": "[Çōƥŷ {name} ·øé·]"
},
"sectionHeader": {
"dragToReorder": "[Đřàğ ţō řéōřđéř ·øé·øé]"
diff --git a/src/shared/i18n/en.json b/src/shared/i18n/en.json
index 4187e1d7..aa99091f 100644
--- a/src/shared/i18n/en.json
+++ b/src/shared/i18n/en.json
@@ -79,7 +79,9 @@
"copyPatch": "Copy Diff as Patch",
"copyFile": "Copy Diff as File",
"applyPatch": "Apply Patch…",
- "pasteTextMode": "Paste Text Mode"
+ "pasteTextMode": "Paste Text Mode",
+ "copyLeft": "Copy Left Side",
+ "copyRight": "Copy Right Side"
},
"view": {
"title": "View",
@@ -241,7 +243,9 @@
"splitView": "split view",
"clear": "clear"
},
- "quickLookAppWide": "Quick look-up (works app-wide)"
+ "quickLookAppWide": "Quick look-up (works app-wide)",
+ "copyLeft": "Copy left side",
+ "copyRight": "Copy right side"
},
"shortcutBar": {
"hideTip": "Hide this bar — bring it back in Settings",
@@ -1452,7 +1456,9 @@
"copiedAsFile": "{name} copied as a file — paste it where you need it.",
"bothSidesLookLike": "Both sides look like {kind} — pretty-print?",
"savedExpiresIn": "Saved — expires in {hours} h.",
- "importedSnippets": "Imported one snippet. | Imported {n} snippets."
+ "importedSnippets": "Imported one snippet. | Imported {n} snippets.",
+ "sideCopied": "Copied {name}",
+ "couldNotCopyTheSide": "Could not copy that side."
},
"errorNotices": {
"unknownError": "Unknown error"
@@ -1612,7 +1618,8 @@
"hiddenByFilter": "\"{name}\" was added, but the current filter hides it."
},
"fileSlot": {
- "chooseSideFile": "Choose {side} file"
+ "chooseSideFile": "Choose {side} file",
+ "copySide": "Copy {name}"
},
"sectionHeader": {
"dragToReorder": "Drag to reorder"
diff --git a/tests/renderer/adapters/textAdapter.test.js b/tests/renderer/adapters/textAdapter.test.js
index 4bfd8b6d..2d0b6348 100644
--- a/tests/renderer/adapters/textAdapter.test.js
+++ b/tests/renderer/adapters/textAdapter.test.js
@@ -48,3 +48,41 @@ describe('textAdapter', () => {
expect(textAdapter.toComparable({ name: 'noext' }).language).toBe('plaintext')
})
})
+
+// A .har is JSON by spec, but it is JSON whose string values hold whole captured
+// HTML responses. Sniffing only sees the first 50k, which cuts a multi-MB
+// capture into unparseable JSON, and the `