diff --git a/README.md b/README.md index 414e2f78..5db08522 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, 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. | +| **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. | | **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. | @@ -72,7 +72,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. +- **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. - **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/screenshots/themes/snippet-colour-amber.png b/docs/screenshots/themes/snippet-colour-amber.png new file mode 100644 index 00000000..66679218 Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-amber.png differ diff --git a/docs/screenshots/themes/snippet-colour-beacon.png b/docs/screenshots/themes/snippet-colour-beacon.png new file mode 100644 index 00000000..8a063f74 Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-beacon.png differ diff --git a/docs/screenshots/themes/snippet-colour-bloom.png b/docs/screenshots/themes/snippet-colour-bloom.png new file mode 100644 index 00000000..84f4c082 Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-bloom.png differ diff --git a/docs/screenshots/themes/snippet-colour-contrast.png b/docs/screenshots/themes/snippet-colour-contrast.png new file mode 100644 index 00000000..578ebc4b Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-contrast.png differ diff --git a/docs/screenshots/themes/snippet-colour-dark.png b/docs/screenshots/themes/snippet-colour-dark.png new file mode 100644 index 00000000..cca5ac3f Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-dark.png differ diff --git a/docs/screenshots/themes/snippet-colour-dim.png b/docs/screenshots/themes/snippet-colour-dim.png new file mode 100644 index 00000000..c90fff3b Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-dim.png differ diff --git a/docs/screenshots/themes/snippet-colour-ember.png b/docs/screenshots/themes/snippet-colour-ember.png new file mode 100644 index 00000000..3ff5853d Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-ember.png differ diff --git a/docs/screenshots/themes/snippet-colour-graphite.png b/docs/screenshots/themes/snippet-colour-graphite.png new file mode 100644 index 00000000..39a12728 Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-graphite.png differ diff --git a/docs/screenshots/themes/snippet-colour-light.png b/docs/screenshots/themes/snippet-colour-light.png new file mode 100644 index 00000000..484227ae Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-light.png differ diff --git a/docs/screenshots/themes/snippet-colour-linen.png b/docs/screenshots/themes/snippet-colour-linen.png new file mode 100644 index 00000000..06ddffc4 Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-linen.png differ diff --git a/docs/screenshots/themes/snippet-colour-matrix.png b/docs/screenshots/themes/snippet-colour-matrix.png new file mode 100644 index 00000000..b21a84a5 Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-matrix.png differ diff --git a/docs/screenshots/themes/snippet-colour-meridian.png b/docs/screenshots/themes/snippet-colour-meridian.png new file mode 100644 index 00000000..0fd5be37 Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-meridian.png differ diff --git a/docs/screenshots/themes/snippet-colour-neon.png b/docs/screenshots/themes/snippet-colour-neon.png new file mode 100644 index 00000000..f4a66f95 Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-neon.png differ diff --git a/docs/screenshots/themes/snippet-colour-nord.png b/docs/screenshots/themes/snippet-colour-nord.png new file mode 100644 index 00000000..207e9510 Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-nord.png differ diff --git a/docs/screenshots/themes/snippet-colour-nyan.png b/docs/screenshots/themes/snippet-colour-nyan.png new file mode 100644 index 00000000..13b978f5 Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-nyan.png differ diff --git a/docs/screenshots/themes/snippet-colour-sepia.png b/docs/screenshots/themes/snippet-colour-sepia.png new file mode 100644 index 00000000..faaf6811 Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-sepia.png differ diff --git a/docs/screenshots/themes/snippet-colour-solar.png b/docs/screenshots/themes/snippet-colour-solar.png new file mode 100644 index 00000000..4728df1f Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-solar.png differ diff --git a/docs/screenshots/themes/snippet-colour-tide.png b/docs/screenshots/themes/snippet-colour-tide.png new file mode 100644 index 00000000..ce1f8e39 Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-tide.png differ diff --git a/docs/screenshots/themes/snippet-colour-vector.png b/docs/screenshots/themes/snippet-colour-vector.png new file mode 100644 index 00000000..6c7c48df Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-vector.png differ diff --git a/docs/screenshots/themes/snippet-colour-volcano.png b/docs/screenshots/themes/snippet-colour-volcano.png new file mode 100644 index 00000000..05c9668c Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-volcano.png differ diff --git a/e2e/quick-look-draft.spec.mjs b/e2e/quick-look-draft.spec.mjs new file mode 100644 index 00000000..765d02a3 --- /dev/null +++ b/e2e/quick-look-draft.spec.mjs @@ -0,0 +1,84 @@ +import { test, expect } from './fixtures.mjs' + +// Composing in the launcher is WRITING, and it was being done in a card sized to +// read one line in. Two things have to hold: the window grows for the job, and +// pressing the chord twice — which is how the launcher is dismissed and brought +// back — does not take an unsaved draft with it. + +const launcher = (app, read) => + app.evaluate(({ BrowserWindow }) => { + const w = BrowserWindow.getAllWindows().find((x) => + x.webContents.getURL().includes('quicklook') + ) + return w ? { bounds: w.getBounds(), visible: w.isVisible() } : null + }, read) + +const bounds = async (app) => (await launcher(app)).bounds +const isVisible = async (app) => (await launcher(app))?.visible === true +const toggle = (page) => page.evaluate(() => window.api.quickLookToggle()) + +async function summon(app, page) { + const [ql] = await Promise.all([app.waitForEvent('window'), toggle(page)]) + await ql.waitForLoadState('domcontentloaded') + await expect(ql.locator('.ql-input')).toBeVisible() + return ql +} + +// Main owns the resize, so the height is polled off the OS window rather than +// off anything the page can report about itself. +const height = (app) => expect.poll(async () => (await bounds(app)).height, { timeout: 4000 }) + +test('composing grows the card, and leaving it gives the size back', async ({ app, page }) => { + const ql = await summon(app, page) + const resting = await bounds(app) + + await ql.locator('.ql-add').click() + await expect(ql.locator('.ql-compose')).toBeVisible() + await height(app).toBeGreaterThan(resting.height * 1.4) + + // It grew from where it already was — down and to the right. Re-centring + // would slide the row under the pointer away mid-click, which is exactly what + // ate two clicks on Edit. + const grown = await bounds(app) + expect(grown.width).toBeGreaterThan(resting.width) + expect({ x: grown.x, y: grown.y }).toEqual({ x: resting.x, y: resting.y }) + + await ql.getByRole('button', { name: 'Cancel' }).click() + await height(app).toBe(resting.height) +}) + +test('a re-summon keeps an unsaved draft, and the card it was written in', async ({ + app, + page +}) => { + const ql = await summon(app, page) + await ql.locator('.ql-add').click() + await ql.locator('.ql-compose-text').fill('line one\nline two\nline three') + await height(app).toBeGreaterThan(600) + const composing = await bounds(app) + + await toggle(page) + await expect.poll(() => isVisible(app), { timeout: 4000 }).toBe(false) + await toggle(page) + await expect.poll(() => isVisible(app), { timeout: 4000 }).toBe(true) + + await expect(ql.locator('.ql-compose')).toBeVisible() + await expect(ql.locator('.ql-compose-text')).toHaveValue('line one\nline two\nline three') + expect((await bounds(app)).height).toBe(composing.height) +}) + +// The other half of the rule: with nothing at stake a summon is still a fresh +// start, or a stale query outlives the search that typed it. +test('a summon with nothing typed into it still starts clean', async ({ app, page }) => { + const ql = await summon(app, page) + await ql.locator('.ql-input').fill('mermaid') + await ql.locator('.ql-add').click() + await expect(ql.locator('.ql-compose')).toBeVisible() + + await toggle(page) + await expect.poll(() => isVisible(app), { timeout: 4000 }).toBe(false) + await toggle(page) + + await expect(ql.locator('.ql-compose')).toBeHidden() + await expect(ql.locator('.ql-input')).toHaveValue('') +}) diff --git a/e2e/quick-look-keyboard.spec.mjs b/e2e/quick-look-keyboard.spec.mjs index 6b7a868b..727d9927 100644 --- a/e2e/quick-look-keyboard.spec.mjs +++ b/e2e/quick-look-keyboard.spec.mjs @@ -166,16 +166,20 @@ test('an unmodified inline edit does not rewrite the stored language', async ({ await expect(ql.locator('.ql-compose-lang option:checked')).toHaveText(/SQL/) await ql.keyboard.press('ControlOrMeta+Enter') + // Searched, not clicked: a click on a row now does what ↵ does — it opens the + // snippet in the main window — and this test wants the preview, not the hand-off. await ql.locator('.ql-input').fill('orders probe') - await ql.locator('.ql-res:not(.ql-res-create)', { hasText: 'Orders probe' }).first().click() + await expect(ql.locator('.ql-pv-name')).toHaveText('Orders probe') await ql.locator('.ql-pv-head button', { hasText: 'Edit' }).click() // Still Auto — the picker would read a bare "SQL" if the language had frozen. await expect(ql.locator('.ql-compose-lang')).toHaveValue('auto') await ql.keyboard.press('ControlOrMeta+Enter') + // Searched, not clicked: a click on a row now does what ↵ does — it opens the + // snippet in the main window — and this test wants the preview, not the hand-off. await ql.locator('.ql-input').fill('orders probe') - await ql.locator('.ql-res:not(.ql-res-create)', { hasText: 'Orders probe' }).first().click() + await expect(ql.locator('.ql-pv-name')).toHaveText('Orders probe') await ql.locator('.ql-pv-head button', { hasText: 'Edit' }).click() await expect(ql.locator('.ql-compose-lang')).toHaveValue('auto') }) @@ -200,19 +204,19 @@ test('changing the language with an arrow key does not discard the draft', async // The launcher hides on blur and is kept warm, so a summon must land on a fresh // search band. This could not pass until focusInput() stopped throwing: it runs // first in onMounted, so it took the quicklook:show registration down with it -// and refresh() never ran at all. -test("a re-summon starts fresh, not on last time's draft", async ({ app, page }) => { +// and refresh() never ran at all. What a summon deliberately KEEPS — an unsaved +// draft, a tool panel mid-conversion — is quick-look-draft.spec.mjs. +test("a re-summon starts fresh, not on last time's search", async ({ app, page }) => { const ql = await summon(app, page) await ql.locator('.ql-input').fill('marker-query') - await ql.keyboard.press('ControlOrMeta+n') - await ql.locator('.ql-compose-text').fill('half-written thought') - await expect(ql.locator('.ql-compose')).toBeVisible() + await ql.keyboard.press('ArrowDown') + await expect(ql.locator('.ql-res.sel')).toBeVisible() await page.evaluate(() => window.api.quickLookToggle()) await page.evaluate(() => window.api.quickLookToggle()) - await expect(ql.locator('.ql-compose')).toBeHidden() await expect(ql.locator('.ql-input')).toBeVisible() await expect(ql.locator('.ql-input')).toHaveValue('') + await expect(ql.locator('.ql-res').first()).toHaveClass(/sel/) }) diff --git a/e2e/quick-look-markup.spec.mjs b/e2e/quick-look-markup.spec.mjs new file mode 100644 index 00000000..ba7efafe --- /dev/null +++ b/e2e/quick-look-markup.spec.mjs @@ -0,0 +1,169 @@ +import { test, expect } from './fixtures.mjs' + +// The launcher can write a snippet, and a Jira or Markdown one is written with +// markup — but the compose panel offered no formatting row at all, so choosing +// either language in the launcher gave a plain textarea while the main editor +// gave a toolbar. Same two languages, same buttons, wherever the writing happens. + +async function summon(app, page) { + const [ql] = await Promise.all([ + app.waitForEvent('window'), + page.evaluate(() => window.api.quickLookToggle()) + ]) + await ql.waitForLoadState('domcontentloaded') + await expect(ql.locator('.ql-input')).toBeVisible() + return ql +} + +const compose = async (app, page) => { + const ql = await summon(app, page) + await ql.locator('.ql-add').click() + await expect(ql.locator('.ql-compose')).toBeVisible() + return ql +} + +const toolbar = (ql) => ql.locator('.ql-compose .format-toolbar') + +test('choosing Markdown brings up its formatting row, and the buttons work', async ({ + app, + page +}) => { + const ql = await compose(app, page) + // Plaintext has no markup, so it has no row — the toolbar is a property of the + // language, not decoration on the panel. + await expect(toolbar(ql)).toHaveCount(0) + + await ql.locator('.ql-compose-lang').selectOption('markdown') + await expect(toolbar(ql)).toBeVisible() + + await ql.locator('.ql-compose-text').fill('release notes') + await ql.locator('.ql-compose-text').evaluate((el) => el.setSelectionRange(0, 7)) + await toolbar(ql).getByRole('button', { name: /Bold/ }).click() + await expect(ql.locator('.ql-compose-text')).toHaveValue('**release** notes') + + // The caret stays inside what was just wrapped, so typing continues there + // rather than after the markers. + const sel = await ql + .locator('.ql-compose-text') + .evaluate((el) => [el.selectionStart, el.selectionEnd]) + expect(sel).toEqual([2, 9]) +}) + +test('choosing Jira brings up ITS row, with its own syntax', async ({ app, page }) => { + const ql = await compose(app, page) + await ql.locator('.ql-compose-lang').selectOption('jira') + await expect(toolbar(ql)).toBeVisible() + + await ql.locator('.ql-compose-text').fill('deploy notes') + await ql.locator('.ql-compose-text').evaluate((el) => el.setSelectionRange(0, 6)) + await toolbar(ql).getByRole('button', { name: /Bold/ }).click() + // Jira's bold is a single asterisk, Markdown's is two. + await expect(ql.locator('.ql-compose-text')).toHaveValue('*deploy* notes') +}) + +// Auto is the default, and it is what the language actually resolves to that +// decides — a body that reads as Markdown gets the Markdown row without anyone +// naming the language. +test('a detected Markdown body gets the row on Auto', async ({ app, page }) => { + const ql = await compose(app, page) + await ql.locator('.ql-compose-text').fill('# Heading\n\n- one\n- two\n\n**bold** text\n') + await expect(toolbar(ql)).toBeVisible() + await expect(ql.locator('.ql-compose-lang')).toHaveValue('auto') +}) + +test('the row goes away again when the language stops having markup', async ({ app, page }) => { + const ql = await compose(app, page) + await ql.locator('.ql-compose-lang').selectOption('markdown') + await expect(toolbar(ql)).toBeVisible() + + await ql.locator('.ql-compose-lang').selectOption('sql') + await expect(toolbar(ql)).toHaveCount(0) +}) + +// Markup is written to be READ as its rendered form, so the launcher shows that +// form beside the raw text and keeps it current as you type — it used to show +// nothing but the syntax, which is the one view the snippet is not for. +const preview = (ql) => ql.locator('.ql-compose-preview') + +test('the rendered form appears beside the syntax, and follows the typing', async ({ + app, + page +}) => { + const ql = await compose(app, page) + await expect(preview(ql)).toHaveCount(0) + + await ql.locator('.ql-compose-lang').selectOption('markdown') + await expect(preview(ql)).toBeVisible() + + await ql.locator('.ql-compose-text').fill('# Release notes\n\n- **one**\n- two\n') + await expect(preview(ql).locator('h1')).toHaveText('Release notes') + await expect(preview(ql).locator('li')).toHaveCount(2) + await expect(preview(ql).locator('strong')).toHaveText('one') + + // Still an editor: the raw text is right there and takes the next keystroke. + await ql.locator('.ql-compose-text').fill('# Release notes\n\n- **one**\n- two\n- three\n') + await expect(preview(ql).locator('li')).toHaveCount(3) + await expect(ql.locator('.ql-compose-text')).toHaveValue(/three/) +}) + +test('Jira renders its own markup, and plaintext renders nothing', async ({ app, page }) => { + const ql = await compose(app, page) + await ql.locator('.ql-compose-lang').selectOption('jira') + await ql.locator('.ql-compose-text').fill('h1. Deploy steps\n\n* first\n* second\n') + await expect(preview(ql).locator('h1')).toHaveText('Deploy steps') + await expect(preview(ql).locator('li')).toHaveCount(2) + + await ql.locator('.ql-compose-lang').selectOption('sql') + await expect(preview(ql)).toHaveCount(0) +}) + +// The four the toolbars gained, driven the way a reader drives them: select, +// press, read the rendered pane back. +test('strikethrough, tasks, nesting and a table all reach the preview', async ({ app, page }) => { + test.setTimeout(120_000) + const ql = await compose(app, page) + await ql.locator('.ql-compose-lang').selectOption('markdown') + const body = ql.locator('.ql-compose-text') + + await body.fill('gone tomorrow') + await body.evaluate((el) => el.setSelectionRange(0, 4)) + await toolbar(ql).getByRole('button', { name: /Strikethrough/ }).click() + await expect(body).toHaveValue('~~gone~~ tomorrow') + await expect(preview(ql).locator('s')).toHaveText('gone') + + await body.fill('ship it') + await body.evaluate((el) => el.setSelectionRange(0, 7)) + await toolbar(ql).getByRole('button', { name: /Task list/ }).click() + await expect(body).toHaveValue('- [ ] ship it') + await expect(preview(ql).locator('input[type=checkbox]')).not.toBeChecked() + + await toolbar(ql).getByRole('button', { name: /^Indent/ }).click() + await expect(body).toHaveValue(' - [ ] ship it') + await toolbar(ql).getByRole('button', { name: /^Outdent/ }).click() + await expect(body).toHaveValue('- [ ] ship it') + + await body.fill('') + await toolbar(ql).getByRole('button', { name: /Table/ }).click() + await expect(preview(ql).locator('th')).toHaveCount(2) + await expect(preview(ql).locator('tbody tr')).toHaveCount(1) + // The first heading is selected, so it is typed over rather than deleted. + await ql.keyboard.type('Env') + await expect(preview(ql).locator('th').first()).toHaveText('Env') +}) + +test('Jira gets nesting and its own table syntax', async ({ app, page }) => { + test.setTimeout(120_000) + const ql = await compose(app, page) + await ql.locator('.ql-compose-lang').selectOption('jira') + const body = ql.locator('.ql-compose-text') + + await body.fill('* one') + await body.evaluate((el) => el.setSelectionRange(0, 5)) + await toolbar(ql).getByRole('button', { name: /^Indent/ }).click() + await expect(body).toHaveValue('** one') + + await body.fill('') + await toolbar(ql).getByRole('button', { name: /Table/ }).click() + await expect(body).toHaveValue('||Column||Column||\n| | |\n') + await expect(preview(ql).locator('th')).toHaveCount(2) +}) diff --git a/e2e/quick-look-mouse.spec.mjs b/e2e/quick-look-mouse.spec.mjs new file mode 100644 index 00000000..5b90af8a --- /dev/null +++ b/e2e/quick-look-mouse.spec.mjs @@ -0,0 +1,67 @@ +import { test, expect } from './fixtures.mjs' + +// The launcher is keyboard-first, but the mouse must not be a dead end: a click +// on a row did nothing but select it, so a tool, the Tools header and the create +// row all looked broken to anyone who reached for the pointer. One click now +// does exactly what ↵ does on that row. +const EXAMPLE = 'Example — Mermaid diagram' + +async function summon(app, page) { + const [ql] = await Promise.all([ + app.waitForEvent('window'), + page.evaluate(() => window.api.quickLookToggle()) + ]) + await ql.waitForLoadState('domcontentloaded') + await expect(ql.locator('.ql-input')).toBeVisible() + return ql +} + +const row = (ql, name) => ql.locator('.ql-res:not(.ql-res-create)', { hasText: name }) + +test('one click opens the Tools section and then a tool', async ({ app, page }) => { + const ql = await summon(app, page) + + await ql.locator('.ql-res.section').click() + await expect(ql.locator('.ql-res.sub').first()).toBeVisible() + + await ql.locator('.ql-input').fill('base64') + await row(ql, 'Base64').click() + await expect(ql.locator('.qc-name')).toHaveText('Base64') +}) + +test('one click on the create row opens the compose panel', async ({ app, page }) => { + const ql = await summon(app, page) + + await ql.locator('.ql-input').fill('Clicked into being') + await ql.locator('.ql-res-create').click() + await expect(ql.locator('.ql-compose')).toBeVisible() + await expect(ql.locator('.ql-compose-name')).toHaveValue('Clicked into being') +}) + +test('one click on a snippet hands it to the main window', async ({ app, page }) => { + await expect(page.getByText(EXAMPLE)).toBeVisible() + const ql = await summon(app, page) + + await ql.locator('.ql-input').fill('Mermaid') + await row(ql, EXAMPLE).click() + + // The same hand-off ↵ performs: the main window opens that snippet. + const opened = page.getByRole('dialog', { name: 'Snippet', exact: true }) + await expect(opened).toBeVisible() + await expect(opened.getByPlaceholder('Snippet name…')).toHaveValue(EXAMPLE) +}) + +// Double-click WAS the activator until a single click became one, so it is the +// gesture most likely to arrive out of habit — and acting on both clicks opened +// the Tools section and shut it again in one go. +test('a double-click opens a row once, not twice', async ({ app, page }) => { + const ql = await summon(app, page) + + await ql.locator('.ql-res.section').dblclick() + await expect(ql.locator('.ql-res.sub').first()).toBeVisible() + + await ql.locator('.ql-input').fill('Doubled into being') + await ql.locator('.ql-res-create').dblclick() + await expect(ql.locator('.ql-compose')).toBeVisible() + await expect(ql.locator('.ql-compose-name')).toHaveValue('Doubled into being') +}) diff --git a/e2e/quick-look.spec.mjs b/e2e/quick-look.spec.mjs index ce2188e4..42ea03ea 100644 --- a/e2e/quick-look.spec.mjs +++ b/e2e/quick-look.spec.mjs @@ -317,7 +317,9 @@ test('an unnamed snippet can be created and then edited in place', async ({ app, const row = ql.locator('.ql-res', { hasText: 'Untitled' }).first() await expect(row).toBeVisible() await expect(row).toContainText(/Untitled \d{4}-\d{2}-\d{2} \d{2}:\d{2}/) - await row.click() + // Selected by searching, not by clicking: a click opens it in the main window. + await ql.locator('.ql-input').fill('Untitled') + await expect(ql.locator('.ql-pv-name')).toContainText('Untitled') // Edit it right here rather than being bounced to the main window. await ql.locator('.ql-pv-head button', { hasText: 'Edit' }).click() @@ -329,7 +331,9 @@ test('an unnamed snippet can be created and then edited in place', async ({ app, await ql.locator('.ql-compose .btn-primary').click() await expect(ql.locator('.ql-compose')).toBeHidden() - // Renamed in place — sentence-cased, and no second copy left behind. + // Renamed in place — sentence-cased, and no second copy left behind. Counted + // on the WHOLE list, so the search that selected it cannot flatter the result. + await ql.locator('.ql-input').fill('') await expect(ql.locator('.ql-res', { hasText: 'Auth token' })).toHaveCount(1) await expect(ql.locator('.ql-res', { hasText: 'Untitled' })).toHaveCount(0) }) diff --git a/e2e/row-tags-toggle.spec.mjs b/e2e/row-tags-toggle.spec.mjs new file mode 100644 index 00000000..c1e7c189 --- /dev/null +++ b/e2e/row-tags-toggle.spec.mjs @@ -0,0 +1,123 @@ +import { + test, + expect, + launchApp, + freshUserDataDir, + firstReadyPage, + newSnippetButton, + openSettings +} from './fixtures.mjs' + +// A tag word sits next to the name on every saved-diff and snippet row. For a +// library where nearly everything carries the same two tags that is noise beside +// the one thing being read — the name — so it can be turned off. Tags themselves +// are untouched: the shelf still filters and the search still finds by them. + +const TOGGLE = 'Show tags on sidebar rows' +const snippetTag = (page) => page.locator('.snippets-section .row .tag-word') +const diffTag = (page) => page.locator('li.diff .tag-word') + +async function seedSnippet(page, name, tags) { + await newSnippetButton(page).click() + const editor = page.getByRole('dialog', { name: 'New Snippet' }) + await editor.getByPlaceholder('Snippet name…').fill(name) + await editor.locator('.editor').click() + await page.keyboard.type('seeded body') + for (const tag of tags) { + await editor.getByPlaceholder('add a tag…').fill(tag) + await editor.getByPlaceholder('add a tag…').press('Enter') + } + await editor.getByRole('button', { name: 'Save', exact: true }).click() + await expect(editor).toBeHidden() +} + +async function seedDiff(page, name, tags) { + const left = page.getByPlaceholder('Paste original text here') + if (!(await left.isVisible().catch(() => false))) { + await page.getByRole('button', { name: 'Paste mode' }).click() + } + await left.fill(`before ${name}`) + await page.getByPlaceholder('Paste changed text here').fill(`after ${name}`) + await page.getByRole('button', { name: 'Compare', exact: true }).click() + await page.getByRole('button', { name: 'Save', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Save diff' }) + await dialog.getByLabel('Name', { exact: true }).fill(name) + for (const tag of tags) { + await dialog.getByPlaceholder('add a tag…').fill(tag) + await dialog.getByPlaceholder('add a tag…').press('Enter') + } + await dialog.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.locator('li.diff', { hasText: name })).toBeVisible() +} + +async function setRowTags(page, on) { + await openSettings(page) + const toggle = page.getByText(TOGGLE) + await expect(toggle).toBeVisible() + const box = page.locator('.setting-toggle', { hasText: TOGGLE }).locator('input') + if ((await box.isChecked()) !== on) await box.click() + await expect(box).toBeChecked({ checked: on }) + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog', { name: 'Settings' })).toBeHidden() +} + +test('the toggle takes the tag word off both kinds of row, and puts it back', async ({ page }) => { + test.setTimeout(120_000) + await seedSnippet(page, 'Rollback runbook', ['ops']) + await seedDiff(page, 'E2E tagged diff', ['ops']) + + await expect(snippetTag(page).first()).toBeVisible() + await expect(diffTag(page).first()).toBeVisible() + + await setRowTags(page, false) + await expect(snippetTag(page)).toHaveCount(0) + await expect(diffTag(page)).toHaveCount(0) + // The name is what the row is for, and it is still there. + await expect(page.locator('.snippets-section .row', { hasText: 'Rollback runbook' })).toBeVisible() + + await setRowTags(page, true) + await expect(snippetTag(page).first()).toBeVisible() + await expect(diffTag(page).first()).toBeVisible() +}) + +// Hiding the WORD is not hiding the tag: both ways of reaching one still work, +// which is the whole reason the row can afford to drop it. +test('the shelf and the search still filter by tag with the words off', async ({ page }) => { + test.setTimeout(120_000) + await seedSnippet(page, 'Rollback runbook', ['ops']) + await seedSnippet(page, 'Untagged note', []) + await setRowTags(page, false) + + const chip = page.locator('.usb-tag', { hasText: 'ops' }).first() + await expect(chip).toBeVisible() + await chip.click() + await expect(page.locator('.snippets-section .row', { hasText: 'Rollback runbook' })).toBeVisible() + await expect(page.locator('.snippets-section .row', { hasText: 'Untagged note' })).toHaveCount(0) + await chip.click() + + await page.getByPlaceholder('Search diffs & snippets…').fill('ops') + await expect(page.locator('.snippets-section .row', { hasText: 'Rollback runbook' })).toBeVisible() + await expect(page.locator('.snippets-section .row', { hasText: 'Untagged note' })).toHaveCount(0) +}) + +test('the choice survives a relaunch', async () => { + test.setTimeout(120_000) + const dir = freshUserDataDir() + let app = await launchApp(dir) + try { + const page = await firstReadyPage(app) + await seedSnippet(page, 'Rollback runbook', ['ops']) + await setRowTags(page, false) + } finally { + await app.close() + } + + app = await launchApp(dir) + try { + const page = await firstReadyPage(app) + await expect(page.locator('.snippets-section .row', { hasText: 'Rollback runbook' })).toBeVisible() + await expect(snippetTag(page)).toHaveCount(0) + } finally { + await app.close() + } +}) diff --git a/e2e/snippet-colour.spec.mjs b/e2e/snippet-colour.spec.mjs new file mode 100644 index 00000000..397b7779 --- /dev/null +++ b/e2e/snippet-colour.spec.mjs @@ -0,0 +1,185 @@ +import { + test, + expect, + launchApp, + freshUserDataDir, + firstReadyPage, + newSnippetButton +} from './fixtures.mjs' + +// A colour the reader picks, painted across the whole row. It is decoration — +// nothing filters, searches or sorts by it — so what has to hold is that it +// SHOWS, that it stays readable on every ground, and that it never eats a state +// the row already had. Only a launched app resolves `oklch(from …)` and +// `color-mix`, so the contrast claims are measured here rather than asserted +// against a stylesheet. + +const row = (page, name) => page.locator('.snippets-section .row', { hasText: name }) +const menu = (page) => page.locator('.rcm') + +async function seedSnippet(page, name) { + await newSnippetButton(page).click() + const editor = page.getByRole('dialog', { name: 'New Snippet' }) + await editor.getByPlaceholder('Snippet name…').fill(name) + await editor.locator('.editor').click() + await page.keyboard.type('seeded body') + await editor.getByRole('button', { name: 'Save', exact: true }).click() + await expect(editor).toBeHidden() +} + +const openMenu = async (page, name) => { + await row(page, name).click({ button: 'right' }) + await expect(menu(page)).toBeVisible() +} + +const pick = async (page, color) => { + await menu(page).locator(`[data-color="${color}"]`).click() + await expect(menu(page)).toHaveCount(0) +} + +// WCAG contrast between the row's own ink and the ground it actually paints, +// read off the live DOM — the wash is a color-mix over the panel, so nothing +// short of a real render knows what it resolves to. +const contrastOfName = (page, name) => + row(page, name).evaluate((el) => { + const lum = (c) => { + const [r, g, b] = c + .match(/[\d.]+/g) + .slice(0, 3) + .map((v) => { + const x = Number(v) / 255 + return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + // The row's background is a mix over the panel; walk up for the ground it + // lands on so the ratio is what the eye actually gets. + const ground = (node) => { + for (let el = node; el; el = el.parentElement) { + const bg = getComputedStyle(el).backgroundColor + const alpha = Number(bg.match(/[\d.]+/g)?.[3] ?? 1) + if (alpha === 1) return bg + } + return 'rgb(255, 255, 255)' + } + const paint = (over, under) => { + const a = over.match(/[\d.]+/g).map(Number) + const b = under.match(/[\d.]+/g).map(Number) + const alpha = a[3] ?? 1 + return `rgb(${[0, 1, 2].map((i) => a[i] * alpha + b[i] * (1 - alpha)).join(',')})` + } + const ink = getComputedStyle(el.querySelector('.nm')).color + const bg = paint(getComputedStyle(el).backgroundColor, ground(el.parentElement)) + const [hi, lo] = [lum(ink), lum(bg)].sort((x, y) => y - x) + return (hi + 0.05) / (lo + 0.05) + }) + +test('right-click paints the row, and the menu remembers what it is', async ({ page }) => { + test.setTimeout(120_000) + await seedSnippet(page, 'Deploy rollback') + + // No colour to start with, so nothing about the row says there could be one. + await expect(row(page, 'Deploy rollback')).not.toHaveAttribute('data-color', /./) + + await openMenu(page, 'Deploy rollback') + await pick(page, 'blue') + await expect(row(page, 'Deploy rollback')).toHaveAttribute('data-color', 'blue') + + // The wash and the 3px edge are both painted, and neither is the panel. + const paint = await row(page, 'Deploy rollback').evaluate((el) => ({ + bg: getComputedStyle(el).backgroundColor, + edge: getComputedStyle(el).boxShadow + })) + expect(paint.bg).not.toMatch(/rgba\(0, 0, 0, 0\)/) + expect(paint.edge).toMatch(/inset/) + + // Reopening lands on what the row already is — a menu that opens on someone + // else's answer recolours by accident. + await openMenu(page, 'Deploy rollback') + await expect(menu(page).locator('[data-color="blue"]')).toHaveAttribute('aria-checked', 'true') + await expect(menu(page).locator('[data-color="none"]')).toHaveAttribute('aria-checked', 'false') + await page.keyboard.press('Escape') + await expect(menu(page)).toHaveCount(0) + await expect(row(page, 'Deploy rollback')).toHaveAttribute('data-color', 'blue') +}) + +test('None takes it off again', async ({ page }) => { + test.setTimeout(120_000) + await seedSnippet(page, 'Standup notes') + await openMenu(page, 'Standup notes') + await pick(page, 'rose') + await expect(row(page, 'Standup notes')).toHaveAttribute('data-color', 'rose') + + await openMenu(page, 'Standup notes') + await pick(page, 'none') + await expect(row(page, 'Standup notes')).not.toHaveAttribute('data-color', /./) +}) + +test('the keyboard drives the menu without the pointer', async ({ page }) => { + test.setTimeout(120_000) + await seedSnippet(page, 'Kafka lag query') + await openMenu(page, 'Kafka lag query') + + // Opens on None (no colour yet); Home is the first colour, → steps on. + await page.keyboard.press('Home') + await page.keyboard.press('ArrowRight') + await page.keyboard.press('Enter') + await expect(menu(page)).toHaveCount(0) + await expect(row(page, 'Kafka lag query')).toHaveAttribute('data-color', 'amber') +}) + +// The claim the design rests on: the name stays readable on the wash. Measured +// on a light ground and on a dark one, for every colour in the palette. +test('every colour keeps the name above the reading floor, light and dark', async ({ page }) => { + test.setTimeout(180_000) + await seedSnippet(page, 'Contrast probe') + const was = await page.evaluate(() => document.documentElement.dataset.theme) + + for (const theme of ['light', 'dark']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + for (const colour of ['rose', 'amber', 'green', 'teal', 'blue', 'violet']) { + await openMenu(page, 'Contrast probe') + await pick(page, colour) + const ratio = await contrastOfName(page, 'Contrast probe') + expect(ratio, `${colour} on ${theme}`).toBeGreaterThanOrEqual(4.5) + } + } + // Put back exactly what was there, rather than toggling to whatever is next. + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), was) +}) + +test('the launcher shows the colour, and it survives a relaunch', async () => { + test.setTimeout(120_000) + const dir = freshUserDataDir() + let app = await launchApp(dir) + try { + const page = await firstReadyPage(app) + await seedSnippet(page, 'Deploy rollback') + await openMenu(page, 'Deploy rollback') + await pick(page, 'violet') + } finally { + await app.close() + } + + app = await launchApp(dir) + try { + const page = await firstReadyPage(app) + await expect(row(page, 'Deploy rollback')).toHaveAttribute('data-color', 'violet') + + const [ql] = await Promise.all([ + app.waitForEvent('window'), + page.evaluate(() => window.api.quickLookToggle()) + ]) + await ql.waitForLoadState('domcontentloaded') + await ql.locator('.ql-input').fill('Deploy rollback') + // Not the create row, which a `hasText` match picks up as well. + const result = ql.locator('.ql-res:not(.ql-res-create)', { hasText: 'Deploy rollback' }) + await expect(result).toHaveAttribute('data-color', 'violet') + // Selected AND coloured: the accent keyline returns, the colour stays. + await expect(result).toHaveClass(/sel/) + const shown = await result.evaluate((el) => getComputedStyle(el).boxShadow) + expect(shown).toMatch(/inset/) + } finally { + await app.close() + } +}) diff --git a/e2e/tag-overflow.spec.mjs b/e2e/tag-overflow.spec.mjs index 681410d0..50149fd2 100644 --- a/e2e/tag-overflow.spec.mjs +++ b/e2e/tag-overflow.spec.mjs @@ -38,11 +38,16 @@ const TAGS = [ test('a long tag list stays bounded and the rest go behind a picker', async ({ page }) => { await saveWithTags(page, 'E2E many tags', TAGS) - // Two rows' worth, whatever the library holds — the seeded examples carry - // tags of their own, so the bar is bounded by the cap, not by this test. + // Bounded by the shelf's own height, whatever the library holds — the seeded + // examples carry tags of their own, so the bar is bounded by the depth, not by + // this test. Nothing is clipped: the count is measured against that height. const shown = await page.locator('.usb-tags .usb-tag').count() - expect(shown).toBeLessThanOrEqual(8) expect(shown).toBeLessThan(TAGS.length) + const box = await page.locator('.usb-shelf').boundingBox() + for (const chip of await page.locator('.usb-shelf > *').all()) { + const at = await chip.boundingBox() + expect(at.y + at.height).toBeLessThanOrEqual(box.y + box.height + 1) + } const more = page.locator('.usb-more') await expect(more).toBeVisible() @@ -55,7 +60,8 @@ test('a long tag list stays bounded and the rest go behind a picker', async ({ p await more.click() const picker = page.getByRole('dialog', { name: 'Collapsed tags' }) await expect(picker.locator('.usb-tag')).toHaveCount(overflow) - expect(overflow).toBeLessThan(TAGS.length) + // The bar and the picker partition the registry — no tag in both, none lost. + expect(shown + overflow).toBeGreaterThanOrEqual(TAGS.length) await picker.getByLabel('Find a tag').fill('juli') await expect(picker.locator('.usb-tag')).toHaveCount(1) diff --git a/e2e/tag-shelf-resize.spec.mjs b/e2e/tag-shelf-resize.spec.mjs index 54752a68..c7a5c889 100644 --- a/e2e/tag-shelf-resize.spec.mjs +++ b/e2e/tag-shelf-resize.spec.mjs @@ -7,25 +7,24 @@ import { newSnippetButton } from './fixtures.mjs' -// The tag shelf's depth has exactly one affordance — the grip — so the drag is -// proven against real layout (quantised on the measured chip row) and its -// persistence is proven the only way it can be: a relaunch of the same profile. -// Only a launched app renders either. - -// The first-run examples already seed four tags (example, mermaid, claude, -// prompt); ten more guarantee the shelf overflows at every depth this test -// visits, so the +N chip stays observable on both sides of the drag. -const TAG_COUNT = 10 -const RESTING_CHIPS = 8 // MIN_TAG_ROWS (2) × TAGS_PER_ROW (4) -const DRAGGED_CHIPS = 12 // one row deeper -const OLD_CEILING_CHIPS = 48 // what twelve rows used to be worth +// The shelf's depth is a HEIGHT the grip drags, and what it shows at that height +// is measured — a chip is as wide as its name, so the four-per-row it used to +// assume made one row of pointer travel worth anything between half a rendered +// row and two. Only a launched app has widths to measure, so all of it is +// proven here: the seam tracking the pointer, the "+N more" count matching what +// was actually cut, and the depth surviving a relaunch. + +// The first-run examples seed four tags (example, mermaid, claude, prompt); +// these guarantee the shelf overflows at every depth this test visits. +const TAG_COUNT = 36 +// Whatever the shelf is dragged to, the sections keep this much of the column. +const LIST_FLOOR = 150 // var(--space-3) under the last chip row, matching the air over the shelf's // separator: the strip's border used to sit 5px under the chips at every depth. const BOTTOM_GAP = 10 -// Whatever the shelf is dragged to, the sections keep this much of the column. -const LIST_FLOOR = 150 // MAX_TAGS is 20 per snippet, so a deep shelf takes several. const PER_SNIPPET = 18 +const GAP = 4 async function seedSnippet(page, name, tags) { await newSnippetButton(page).click() @@ -48,6 +47,9 @@ async function seedTags(page, count = TAG_COUNT) { } } +const shelfHeight = async (page) => (await page.locator('.usb-shelf').boundingBox()).height +const rowStep = async (page) => (await page.locator('.usb-tag').first().boundingBox()).height + GAP + async function dragShelf(page, toY) { const box = await page.locator('.usb-shelf-grip').boundingBox() const x = box.x + box.width / 2 @@ -58,11 +60,6 @@ async function dragShelf(page, toY) { await page.mouse.up() } -async function dragShelfDownOneRow(page) { - const rowStep = (await page.locator('.usb-tag').first().boundingBox()).height + 4 - await dragShelf(page, (y) => y + rowStep) -} - // The air between the last chip row and the strip's border, which is the same // number at every depth. async function bottomGap(page) { @@ -87,40 +84,64 @@ async function expectHandleOnTheSeam(page) { expect(onSeam).toContain('usb-shelf-grip') } -test('the shelf grip deepens the tag rows, and the depth survives a relaunch', async () => { - test.setTimeout(90_000) +// Every chip the shelf shows is WHOLLY inside it: the count is measured, so a +// chip clipped at the bottom edge means the measurement is wrong. +async function expectNothingClipped(page) { + const box = await page.locator('.usb-shelf').boundingBox() + const chips = await page.locator('.usb-shelf > *').all() + for (const chip of chips) { + const at = await chip.boundingBox() + expect(at.y + at.height).toBeLessThanOrEqual(box.y + box.height + 1) + } +} + +// "+N more" opens the N that did not fit — not the whole registry with the N +// promised lost inside it. +async function expectOverflowExact(page) { + const shown = await page.locator('.usb-tag').count() + const label = await page.locator('.usb-more').textContent() + const promised = Number(label.match(/\d+/)[0]) + + await page.locator('.usb-more').click() + const picker = page.locator('.picker') + await expect(picker.locator('.tag-chip')).toHaveCount(promised) + await page.locator('.picker-backdrop').click({ position: { x: 5, y: 5 } }) + await expect(picker).toHaveCount(0) + return shown + promised +} + +test('the grip moves the shelf with the pointer, and the depth survives a relaunch', async () => { + test.setTimeout(120_000) const dir = freshUserDataDir() let app = await launchApp(dir) + let dragged try { const page = await firstReadyPage(app) await seedTags(page) - const chips = page.locator('.usb-tag') - await expect(chips).toHaveCount(RESTING_CHIPS) - await expect(page.locator('.usb-more')).toBeVisible() - await expectHandleOnTheSeam(page) + await expectNothingClipped(page) + const total = await expectOverflowExact(page) const restingGap = await bottomGap(page) expect(restingGap).toBeGreaterThanOrEqual(BOTTOM_GAP) - // "+6 more" opens the SIX that did not fit — not the whole registry with - // the promised six lost inside it. - await page.locator('.usb-more').click() - const picker = page.locator('.picker') - await expect(picker).toBeVisible() - await expect(picker.locator('.tag-chip')).toHaveCount(6) - await expect(picker.locator('.tag-chip', { hasText: 'tag-05' })).toBeVisible() - await expect(picker.locator('.tag-chip', { hasText: 'example' })).toHaveCount(0) - await page.locator('.picker-backdrop').click({ position: { x: 5, y: 5 } }) - await expect(picker).toHaveCount(0) - - await dragShelfDownOneRow(page) - - // One row deeper: four more chips show and the overflow count falls in step. - await expect(chips).toHaveCount(DRAGGED_CHIPS) - await expect(page.locator('.usb-more')).toContainText('+2') - // And the air under the shelf is the air it had before the drag. + const step = await rowStep(page) + const before = await shelfHeight(page) + const shownBefore = await page.locator('.usb-tag').count() + + // Three rows of travel is three rows of shelf — within the rounding that + // lands it on a whole row, not the half-to-double it used to be. + await dragShelf(page, (y) => y + 3 * step) + const after = await shelfHeight(page) + expect(after - before).toBeGreaterThan(3 * step - GAP) + expect(after - before).toBeLessThan(3 * step + GAP) + + expect(await page.locator('.usb-tag').count()).toBeGreaterThan(shownBefore) + await expectNothingClipped(page) + expect(await expectOverflowExact(page)).toBe(total) expect(await bottomGap(page)).toBeCloseTo(restingGap, 0) + + dragged = await page.locator('.usb-tag').count() } finally { await app.close() } @@ -129,25 +150,25 @@ test('the shelf grip deepens the tag rows, and the depth survives a relaunch', a app = await launchApp(dir) try { const page = await firstReadyPage(app) - await expect(page.locator('.usb-tag')).toHaveCount(DRAGGED_CHIPS) + await expect(page.locator('.usb-tag')).toHaveCount(dragged) } finally { await app.close() } }) -// Dragged to the floor of the window: the shelf goes far deeper than the dozen -// rows it used to stop at, and the sections it shares the column with keep a -// usable slice of it — squeezed out, they take the grip off the bottom edge with -// them and there is no way left to make the shelf small again. -test('the shelf deepens past the old ceiling without swallowing the sidebar', async ({ page }) => { +// Dragged to the floor of the window: the shelf goes as deep as the column +// allows, and the sections it shares that column with keep a usable slice — +// squeezed out, they take the grip off the bottom edge with them and there is no +// way left to make the shelf small again. +test('the shelf deepens to the floor without swallowing the sidebar', async ({ page }) => { test.setTimeout(120_000) await seedTags(page, 108) - await expect(page.locator('.usb-tag')).toHaveCount(RESTING_CHIPS) + const resting = await page.locator('.usb-tag').count() const floor = await page.evaluate(() => window.innerHeight - 4) await dragShelf(page, () => floor) - expect(await page.locator('.usb-tag').count()).toBeGreaterThan(OLD_CEILING_CHIPS) + expect(await page.locator('.usb-tag').count()).toBeGreaterThan(resting * 3) const aside = await page.locator('.saved').boundingBox() const list = await page.locator('.usb-scroll').boundingBox() @@ -157,3 +178,72 @@ test('the shelf deepens past the old ceiling without swallowing the sidebar', as expect(await bottomGap(page)).toBeGreaterThanOrEqual(BOTTOM_GAP) await expectHandleOnTheSeam(page) }) + +// A drag was the only way to ask, and the seam carried no keyboard at all. +test('double-click opens the shelf to every tag, and rests it again', async ({ page }) => { + test.setTimeout(120_000) + await seedTags(page, 24) + const resting = await shelfHeight(page) + await expect(page.locator('.usb-more')).toBeVisible() + + await page.locator('.usb-shelf-grip').dblclick() + await expect(page.locator('.usb-more')).toHaveCount(0) + expect(await shelfHeight(page)).toBeGreaterThan(resting) + await expectNothingClipped(page) + + await page.locator('.usb-shelf-grip').dblclick() + await expect(page.locator('.usb-more')).toBeVisible() + expect(await shelfHeight(page)).toBeCloseTo(resting, 0) +}) + +// LONG names, deliberately: wide chips do not fill the rows the stored height +// pays for, so the box hugs its chips and renders shorter than the setting. With +// the keys anchored on what was rendered, ↓ wrote a shallower depth and moved +// nothing, and the ↑ after it fell two rows. Short `tag-NN` names fill their +// rows, which is why the first version of this test never saw it. +test('the arrow keys step a row at a time even when the chips do not fill it', async ({ page }) => { + test.setTimeout(120_000) + const names = Array.from({ length: 30 }, (_, i) => `platform-migration-phase-${i + 10}`) + for (let at = 0; at < names.length; at += PER_SNIPPET) { + await seedSnippet(page, `Wide farm ${at}`, names.slice(at, at + PER_SNIPPET)) + } + + const depth = async () => + Number( + (await page.evaluate(() => document.querySelector('.usb-shelf').style.maxHeight)).replace( + 'px', + '' + ) + ) + const step = await rowStep(page) + + // The first press snaps the resting default onto the measured row grid, so it + // is the presses AFTER that which must each be worth exactly one row. + await page.locator('.usb-shelf-grip').focus() + await page.keyboard.press('ArrowDown') + const onGrid = await depth() + + await page.keyboard.press('ArrowDown') + const deeper = await depth() + expect(deeper - onGrid).toBeGreaterThan(step - 1) + expect(deeper - onGrid).toBeLessThan(step + 1) + + // And back to exactly where it was — the ↑ that used to fall two rows. + await page.keyboard.press('ArrowUp') + expect(await depth()).toBe(onGrid) +}) + +test('the arrow keys deepen the shelf a row at a time', async ({ page }) => { + test.setTimeout(120_000) + await seedTags(page, 24) + const step = await rowStep(page) + const before = await shelfHeight(page) + + await page.locator('.usb-shelf-grip').focus() + await page.keyboard.press('ArrowDown') + await expect.poll(() => shelfHeight(page)).toBeGreaterThan(before + step - GAP - 1) + expect(await shelfHeight(page)).toBeLessThan(before + step + GAP) + + await page.keyboard.press('ArrowUp') + await expect.poll(() => shelfHeight(page)).toBeCloseTo(before, 0) +}) diff --git a/scripts/lib/legacySize.mjs b/scripts/lib/legacySize.mjs index 3040f809..bcf51c6f 100644 --- a/scripts/lib/legacySize.mjs +++ b/scripts/lib/legacySize.mjs @@ -9,13 +9,12 @@ export const LEGACY_SIZE = { 'src/main/hashDiff.js': { file: 335 }, 'src/main/lineIndexCore.js': { fn: 67 }, 'src/main/menu.js': { fn: 79 }, - 'src/main/quickLook.js': { file: 253 }, 'src/main/sealing.js': { file: 388 }, 'src/main/share.js': { fn: 205, file: 510 }, 'src/main/window.js': { fn: 69 }, 'src/renderer/src/composables/useMatrixRain.js': { fn: 96 }, 'src/renderer/src/composables/useMonacoInput.js': { fn: 96 }, - 'src/renderer/src/composables/useQuickLook.js': { fn: 211 }, + 'src/renderer/src/composables/useQuickLook.js': { fn: 202 }, 'src/renderer/src/composables/useQuickLookKeys.js': { fn: 97 }, 'src/renderer/src/composables/useSnippetDraft.js': { fn: 182 }, 'src/renderer/src/composables/useSnippetPreview.js': { fn: 73 }, @@ -26,7 +25,7 @@ export const LEGACY_SIZE = { 'src/renderer/src/menus.js': { fn: 87 }, 'src/renderer/src/monaco-mermaid.js': { fn: 94 }, 'src/renderer/src/stores/diffStore.js': { file: 747 }, - 'src/renderer/src/stores/snippetStore.js': { file: 470 }, + 'src/renderer/src/stores/snippetStore.js': { file: 448 }, 'src/renderer/src/stores/tabsStore.js': { file: 323 }, 'src/renderer/src/stores/vaultStore.js': { file: 362 }, 'src/renderer/src/utils/structuralDiff.js': { file: 279 }, diff --git a/scripts/theme-sweep.mjs b/scripts/theme-sweep.mjs index f388a8bb..62dbdc82 100644 --- a/scripts/theme-sweep.mjs +++ b/scripts/theme-sweep.mjs @@ -681,6 +681,47 @@ const SURFACES = [ 'empty-section prompt': ['.empty-cta p', DIM] } }, + { + name: 'snippet-colour', + // A colour the reader picked, painted across the row, plus the popover that + // sets it. Both are composed rather than tokenised — the row's ground is the + // picked ink re-lightened to --tag-l and mixed 16% into the panel, so no + // static scan can know what it lands on. The row's meta ink is the reason + // this surface exists: at --text-dim it falls under the 3.0 floor on nord + // (2.97) and sepia (2.72), which is why the coloured row steps to + // --text-hint and why that step is measured here on all twenty. + open: async (page) => { + const row = page.locator('.snippets-section .row').first() + await row.waitFor() + await row.click({ button: 'right' }) + await page.locator('.rcm').waitFor() + // Teal: the darkest of the six once re-lightened on a light ground, so the + // wash it makes is the worst case the name has to stay readable on. + await page.locator('.rcm [data-color="teal"]').click() + await page.locator('.rcm').waitFor({ state: 'detached' }) + await page.locator('.row[data-color]').first().waitFor() + // Reopened, so the menu's own inks are on screen to measure with it. + await row.click({ button: 'right' }) + await page.locator('.rcm').waitFor() + await page.locator('.rcm [data-color="rose"]').hover() + }, + // Put the library back the way the rest of the walk expects it. + close: async (page) => { + await page.locator('.rcm [data-color="none"]').click() + await page.locator('.rcm').waitFor({ state: 'detached' }) + }, + probes: { + 'name on a coloured row': ['.row[data-color] .nm', TEXT], + 'age on a coloured row': ['.row[data-color] .when', DIM], + 'menu label': ['.rcm-hd', DIM], + // The card the popover floats on, and the keyline that separates it from + // whatever it covers. + 'menu keyline': ['.rcm', SEPARATOR, 'border'], + // The cursor's mark: an accent keyline is the only thing saying which + // target a keypress would take. + 'cursor keyline': ['.rcm-sw.hot', DIM, 'border'] + } + }, { name: 'waiting-for-second', // One side loaded. Dashed rim, tag, name and hint all sit on the pane diff --git a/src/main/quickLook.js b/src/main/quickLook.js index 42c809b2..a3ef8594 100644 --- a/src/main/quickLook.js +++ b/src/main/quickLook.js @@ -11,12 +11,14 @@ import { join } from 'path' import { DEV_URL } from './env' import { readSettings } from './appData' import { appendLog } from './logger' -import { allowsWhileFocused } from './quickLookFocus' +import { allowMainFocus, hideLauncher, isCapturingShortcut } from './quickLookFocus' import { defaultQuickLookShortcut } from '../shared/shortcuts' import { + keepOnScreen, placeWindow, displayForPoint, launcherDiagnostics, + launcherSize, launcherSpaceBehavior, needsMainWindow, storedAccelerator, @@ -28,6 +30,9 @@ import { const DEFAULT_ACCELERATOR = defaultQuickLookShortcut(process.platform) let win = null +// Which size the card is at. Survives a hide, so re-summoning mid-draft brings +// back the window the draft was being written in. +let mode = 'default' // The accelerator currently registered, so a change unregisters exactly it. let currentAccelerator = null // Injected by registerQuickLook, so this module never owns the main window. @@ -36,10 +41,14 @@ let createMain = null function build() { const w = new BrowserWindow({ // The card is flat and fills the window, so these are its exact dimensions. - width: 692, - height: 452, + ...launcherSize(mode), show: false, frame: false, + // The launcher is summoned OVER another app, so it is showing while Diff Bro + // is not the active app. Without this macOS spends the first click on + // activating the window and never delivers it — every row and button in the + // card ignored the first press. + acceptsFirstMouse: true, transparent: true, backgroundColor: '#00000000', resizable: false, @@ -103,7 +112,7 @@ export const isLauncher = (candidate) => !!candidate && candidate === win // Also ends the un-focusable state hideLauncher leaves behind, without which // focus() below is a silent no-op. export function ensureMainWindow() { - allowMainFocus() + allowMainFocus(mainWindow()) const main = needsMainWindow(BrowserWindow.getAllWindows(), win) ? createMain?.() : mainWindow() if (!main) return null if (main.isMinimized()) main.restore() @@ -134,77 +143,61 @@ function logDiag(event, displays, cursor, launcher) { }) } -// Repositioned every summon onto the display holding the pointer. +// Repositioned every summon onto the display holding the pointer, at whatever +// size its current job asks for. function reveal() { const w = ensure() const point = screen.getCursorScreenPoint() const displays = screen.getAllDisplays() const display = displayForPoint(displays, point) ?? screen.getPrimaryDisplay() - const { x, y } = placeWindow(display.workArea, w.getBounds()) - logDiag('reveal', displays, point, { - x, - y, - width: w.getBounds().width, - height: w.getBounds().height - }) - w.setPosition(x, y) + const size = launcherSize(mode, display.workArea) + const { x, y } = placeWindow(display.workArea, size) + logDiag('reveal', displays, point, { x, y, ...size }) + resize(w, { x, y, ...size }) // Separate Pinia instance — the renderer re-reads its library and refocuses. w.webContents.send('quicklook:show') w.show() w.focus() } -// On macOS, hiding the launcher while DiffBro is active raises the app's next -// window (the main window) to the front. app.hide() would prevent that but hides -// the main window too, which the next summon then drags back up. So instead make -// the main window briefly non-focusable: the OS can't make it key, the app -// deactivates back to the previous app, and the main window stays put. -// The un-focusable window above is a timed state, so anything that wants to -// focus the main window must end it first — otherwise focus() is a silent no-op -// and the window surfaces without keyboard focus. -let refocusTimer = null -export function allowMainFocus() { - if (refocusTimer) { - clearTimeout(refocusTimer) - refocusTimer = null - } - const main = mainWindow() - if (main && !main.isDestroyed()) main.setFocusable(true) +function dismiss() { + logDiag('hide', screen.getAllDisplays(), screen.getCursorScreenPoint(), win?.getBounds()) + hideLauncher({ launcher: win, main: mainWindow(), platform: process.platform }) } -function hideLauncher() { - logDiag('hide', screen.getAllDisplays(), screen.getCursorScreenPoint(), win?.getBounds()) - if (process.platform !== 'darwin') { - win?.hide() - return - } - const main = mainWindow() - if (main && main.isVisible() && !main.isMinimized()) { - main.setFocusable(false) - win?.hide() - if (refocusTimer) clearTimeout(refocusTimer) - refocusTimer = setTimeout(() => { - refocusTimer = null - if (!main.isDestroyed()) main.setFocusable(true) - }, 300) - } else { - win?.hide() - } +// The renderer names the JOB and main decides what it is worth in pixels. It +// grows from where the card already IS: re-centring slides the row under the +// pointer away mid-click. +function setMode(next) { + mode = next === 'compose' ? 'compose' : 'default' + const w = ensure() + const at = w.getBounds() + const display = screen.getDisplayMatching(at) + const size = launcherSize(mode, display.workArea) + resize(w, { ...keepOnScreen(at, size, display.workArea), ...size }) +} + +// The card is not the reader's to resize, but `resizable: false` is documented +// to lock the min/max size on Windows — where it would silently refuse this and +// leave the compose card at the resting size. Lifted for the write only; macOS +// and Linux do not need it and are unaffected. +function resize(w, bounds) { + w.setResizable(true) + w.setBounds(bounds) + w.setResizable(false) } export function toggleQuickLook() { const w = ensure() - if (w.isVisible()) hideLauncher() + if (w.isVisible()) dismiss() else reveal() } -// Global-shortcut entry point only (menu/IPC toggle unconditionally): skip -// revealing when you're already in the app, e.g. capturing a new shortcut in -// Settings — the keypress would otherwise pop the launcher over the field. +// Global-shortcut entry point only (menu/IPC toggle unconditionally): the one +// thing it must not interrupt is the Settings capture field, where the chord +// being typed IS the input. function onShortcut() { - const w = ensure() - const main = BrowserWindow.getAllWindows().find((x) => x !== w) - if (!w.isVisible() && main?.isFocused() && !allowsWhileFocused()) return + if (!ensure().isVisible() && isCapturingShortcut()) return toggleQuickLook() } @@ -239,7 +232,8 @@ export function registerQuickLook(openMainWindow) { if (!res.ok) registerShortcut(DEFAULT_ACCELERATOR) ipcMain.handle('quicklook:toggle', () => toggleQuickLook()) ipcMain.handle('quicklook:setShortcut', (_e, accel) => registerShortcut(accel)) - ipcMain.handle('quicklook:hide', () => hideLauncher()) + ipcMain.handle('quicklook:hide', () => dismiss()) + ipcMain.handle('quicklook:mode', (_e, next) => setMode(next)) ipcMain.handle('quicklook:open', (_e, payload) => openInMain(payload)) // Process-wide OS registrations — release on quit. app.on('will-quit', () => globalShortcut.unregisterAll()) @@ -250,4 +244,6 @@ export function registerQuickLook(openMainWindow) { export function destroyQuickLook() { if (win && !win.isDestroyed()) win.destroy() win = null + // The draft went with the renderer, so the card it was written in goes too. + mode = 'default' } diff --git a/src/main/quickLookCore.js b/src/main/quickLookCore.js index e33abb49..3609dd92 100644 --- a/src/main/quickLookCore.js +++ b/src/main/quickLookCore.js @@ -8,6 +8,50 @@ const TOP_FRACTION = 0.28 const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, Math.max(lo, hi))) +// What the launcher is worth at each job. A Map rather than an object literal +// because the mode arrives from the renderer — `SIZES['constructor']` on a +// literal is a truthy hit with no width. +const SIZES = new Map([ + ['default', { width: 692, height: 452 }], + ['compose', { width: 960, height: 720 }] +]) +// Air left around the card, so a size never runs edge to edge on a small display. +const SCREEN_MARGIN = 80 +const SMALLEST = { width: 420, height: 320 } + +/** + * The window size for a launcher mode, never larger than the display holding it. + * @param {string} mode 'default' | 'compose'; anything else resolves to default + * @param {{width:number,height:number}} [workArea] + * @returns {{width:number,height:number}} + */ +export function launcherSize(mode, workArea) { + const want = SIZES.get(mode) ?? SIZES.get('default') + const fit = (px, available, floor) => + Number.isFinite(available) ? Math.min(px, Math.max(floor, available - SCREEN_MARGIN)) : px + return { + width: Math.round(fit(want.width, workArea?.width, SMALLEST.width)), + height: Math.round(fit(want.height, workArea?.height, SMALLEST.height)) + } +} + +/** + * The top-left a card KEEPS when it changes size: growing for a job moves the + * window's edges, never its origin. Re-centring instead slides every row + * sideways out from under the pointer that just clicked one — which is what a + * click on Edit did, twice, before this existed. + * @param {{x:number,y:number}} origin where the card already is + * @param {{width:number,height:number}} size what it is about to be + * @param {{x:number,y:number,width:number,height:number}} workArea + * @returns {{x:number,y:number}} the origin, pulled back inside the work area + */ +export function keepOnScreen(origin, size, workArea) { + return { + x: Math.round(clamp(origin.x, workArea.x, workArea.x + workArea.width - size.width)), + y: Math.round(clamp(origin.y, workArea.y, workArea.y + workArea.height - size.height)) + } +} + /** * @param {{x:number,y:number,width:number,height:number}} workArea * @param {{width:number,height:number}} win diff --git a/src/main/quickLookFocus.js b/src/main/quickLookFocus.js index 5dc94a32..d47fde51 100644 --- a/src/main/quickLookFocus.js +++ b/src/main/quickLookFocus.js @@ -1,19 +1,65 @@ import { ipcMain } from 'electron' -// The global shortcut deliberately does nothing while Diff Bro itself is -// focused: capturing a new binding in Settings would otherwise pop the launcher -// over the field being typed into. The onboarding tour asks the user to press -// the chord WHILE the app is in front, so it lifts the guard for that one step -// and puts it straight back. -let allowed = false +// Focus policy around the launcher, kept out of the window glue (quickLook.js) +// so both halves unit-test with plain objects: what silences the global +// shortcut, and the macOS dance that stops a dismissal dragging the main window +// to the front. -/** @returns {boolean} whether a focused main window should still summon it */ -export const allowsWhileFocused = () => allowed +// The one thing the global shortcut must not interrupt: the Settings capture +// field, where the chord being typed IS the input. Everywhere else it summons — +// including with Diff Bro itself in front, which is where a launcher is most +// often wanted and where it used to do nothing at all. +let capturing = false + +/** @returns {boolean} whether Settings is waiting for a chord right now */ +export const isCapturingShortcut = () => capturing export function registerQuickLookFocusIpc() { // Boolean only: this flips an existing, user-triggered behaviour on and off. // It names no window, no path and no key. - ipcMain.on('quicklook:allow-while-focused', (_e, value) => { - allowed = value === true + ipcMain.on('quicklook:capturing-shortcut', (e, value) => { + capturing = value === true + // MAIN owns the lifetime, not the window that armed it: a renderer closed or + // reloaded mid-capture never sends the false, and the chord would stay dead + // for the rest of the session with no window left to explain why. + if (capturing) e.sender.once('destroyed', () => (capturing = false)) }) } + +// On macOS, hiding the launcher while Diff Bro is active raises the app's next +// window (the main window) to the front. app.hide() would prevent that but hides +// the main window too, which the next summon then drags back up. So instead make +// the main window briefly non-focusable: the OS can't make it key, the app +// deactivates back to the previous app, and the main window stays put. +const REFOCUS_MS = 300 +let refocusTimer = null + +// The un-focusable window above is a timed state, so anything that wants to +// focus the main window must end it first — otherwise focus() is a silent no-op +// and the window surfaces without keyboard focus. +export function allowMainFocus(main) { + if (refocusTimer) { + clearTimeout(refocusTimer) + refocusTimer = null + } + if (main && !main.isDestroyed()) main.setFocusable(true) +} + +/** + * Hide the launcher without handing the front to the main window. + * @param {object} o + * @param {{hide: () => void}|null} o.launcher + * @param {object|null} o.main + * @param {string} o.platform + */ +export function hideLauncher({ launcher, main, platform }) { + const parks = platform === 'darwin' && !!main && main.isVisible() && !main.isMinimized() + if (parks) main.setFocusable(false) + launcher?.hide() + if (!parks) return + if (refocusTimer) clearTimeout(refocusTimer) + refocusTimer = setTimeout(() => { + refocusTimer = null + if (!main.isDestroyed()) main.setFocusable(true) + }, REFOCUS_MS) +} diff --git a/src/preload/index.js b/src/preload/index.js index 193c8a20..29b09647 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -173,9 +173,12 @@ contextBridge.exposeInMainWorld('api', { // renderer receives that pick. Shared preload → both windows see these, but // each only wires the half it uses. quickLookToggle: () => ipcRenderer.invoke('quicklook:toggle'), - // The tour's "press it now" step, which runs with this window in front. - quickLookAllowWhileFocused: (on) => - ipcRenderer.send('quicklook:allow-while-focused', on === true), + // Settings → Shortcuts, while its capture field is armed: the chord being + // typed is the input, so the shortcut must not answer it. + quickLookCapturingShortcut: (on) => ipcRenderer.send('quicklook:capturing-shortcut', on === true), + // Launcher window: which job the card is doing, so main can size it. A NAME, + // never dimensions — the renderer does not get to decide how big a window is. + quickLookMode: (mode) => ipcRenderer.invoke('quicklook:mode', String(mode)), // Settings → Shortcuts: apply a new summon accelerator live. Resolves to // { ok } or { ok:false, error } ('unavailable' / 'invalid'). quickLookSetShortcut: (accel) => ipcRenderer.invoke('quicklook:setShortcut', accel), diff --git a/src/renderer/src/components/AppDialogs.vue b/src/renderer/src/components/AppDialogs.vue index 9de7df29..69f52911 100644 --- a/src/renderer/src/components/AppDialogs.vue +++ b/src/renderer/src/components/AppDialogs.vue @@ -15,6 +15,7 @@ import TextToolDialog from './TextToolDialog.vue' import EncryptDecryptDialog from './EncryptDecryptDialog.vue' import SnippetEditorDialog from './SnippetEditorDialog.vue' import SnippetHistoryDialog from './SnippetHistoryDialog.vue' +import SnippetColorMenu from './SnippetColorMenu.vue' import SnippetPassphraseDialog from './SnippetPassphraseDialog.vue' import SnippetDeleteDialog from './SnippetDeleteDialog.vue' import SnippetFillDialog from './SnippetFillDialog.vue' @@ -75,6 +76,7 @@ const conflicts = useConflictsStore() + diff --git a/src/renderer/src/components/JiraRendered.vue b/src/renderer/src/components/JiraRendered.vue index 11950735..4766baf4 100644 --- a/src/renderer/src/components/JiraRendered.vue +++ b/src/renderer/src/components/JiraRendered.vue @@ -5,6 +5,7 @@ import { computed } from 'vue' import { parseJira } from '../utils/jiraRender' import { arrayOfShape } from '../utils/props' import JiraInline from './JiraInline.vue' +import JiraTable from './JiraTable.vue' const props = defineProps({ content: { type: String, default: null }, @@ -30,11 +31,13 @@ const indent = (depth) => (depth > 1 ? { marginInlineStart: `${(depth - 1) * 16} -
    -
  • +
      +
    • +
    +
    diff --git a/src/renderer/src/components/JiraTable.vue b/src/renderer/src/components/JiraTable.vue new file mode 100644 index 00000000..83a48e09 --- /dev/null +++ b/src/renderer/src/components/JiraTable.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/src/renderer/src/components/QuickLookCompose.vue b/src/renderer/src/components/QuickLookCompose.vue index 16363aca..16ab4730 100644 --- a/src/renderer/src/components/QuickLookCompose.vue +++ b/src/renderer/src/components/QuickLookCompose.vue @@ -5,6 +5,11 @@ import { computed, nextTick, toRef } from 'vue' import { useCaretBackOut } from '../composables/useCaretBackOut' import { useHighlightedInput } from '../composables/useHighlightedInput' +import { useFormatToolbar } from '../composables/useFormatToolbar' +import { useTextareaMarkup } from '../composables/useTextareaMarkup' +import FormatToolbar from './FormatToolbar.vue' +import JiraRendered from './JiraRendered.vue' +import MarkdownRendered from './MarkdownRendered.vue' import SnippetNameField from './SnippetNameField.vue' import { SNIPPET_LANGUAGES } from '../utils/detectLanguage' import { t } from '../i18n' @@ -24,6 +29,14 @@ const emit = defineEmits(['save', 'cancel']) const { textareaEl, overlayEl, lines, isPlain, onScroll, onCompositionStart, onCompositionEnd } = useHighlightedInput({ text: body, language: toRef(props, 'resolvedLanguage') }) +// The two languages that are written IN markup get the same row of buttons the +// main editor gives them. Driven off the RESOLVED language, so a body detected +// as Markdown on Auto gets it without anyone naming the language. +const isMarkdown = computed(() => props.resolvedLanguage === 'markdown') +const hasMarkup = computed(() => isMarkdown.value || props.resolvedLanguage === 'jira') +const { applySelectionEdit } = useTextareaMarkup(textareaEl, body) +const { actions: markupActions, applyAction } = useFormatToolbar({ isMarkdown, applySelectionEdit }) + // Auto carries what it resolved to, so the picker doubles as the readout and // there is no second chip saying the same thing. const labelOf = (id) => @@ -68,34 +81,44 @@ defineExpose({ focus: () => nextTick(() => textareaEl.value?.focus()) }) + +
    -
    - - - +
    +
    + + + +
    + +
    + + +
    diff --git a/src/renderer/src/components/QuickLookResults.vue b/src/renderer/src/components/QuickLookResults.vue index 445f76d5..e1054f73 100644 --- a/src/renderer/src/components/QuickLookResults.vue +++ b/src/renderer/src/components/QuickLookResults.vue @@ -4,6 +4,7 @@ import { ref, watch } from 'vue' import { useSnippetStore } from '../stores/snippetStore' import { languageMonogram } from '../utils/languageMonogram' +import { rowColorHex, rowColorId } from '../utils/rowColor' import { isMac } from '../keys' import AppIcon from './AppIcon.vue' @@ -14,7 +15,20 @@ const props = defineProps({ toolsOpen: { type: Boolean, default: false } }) const selected = defineModel('selected', { type: Number, required: true }) -defineEmits(['choose']) +const emit = defineEmits(['choose']) + +// One click does exactly what ↵ does on that row. Selecting without activating +// left a tool, the Tools header and the create row looking dead to the pointer — +// nothing on them says a second click is what opens them. +// +// The REPEAT is dropped: double-click was the activator until this changed, so +// it is the gesture most likely to arrive out of habit, and acting on both +// clicks opened the Tools section and shut it again in one gesture. +function activate(i, e) { + if (e.detail > 1) return + selected.value = i + emit('choose', i) +} const store = useSnippetStore() const listEl = ref(null) @@ -24,6 +38,11 @@ const mono = (lang) => languageMonogram(lang) const monoStyle = (it) => ({ '--fam': it.kind === 'snippet' ? mono(it.lang).family : '' }) const monoText = (it) => (it.kind === 'snippet' ? mono(it.lang).label : '') const tagStyle = (it) => ({ '--tc': store.colorOf(it.tags?.[0]) }) +// The snippet's own colour, painted the way the sidebar row paints it. +const colorStyle = (it) => { + const hex = rowColorHex(it.color) + return hex ? { '--snip-color': hex } : null +} const rowIcon = () => (props.toolsOpen ? 'chevron-down' : 'chevron-right') const NEW_KEY = isMac ? '⌘N' : 'Ctrl+N' const KIND_LABEL = { @@ -59,8 +78,9 @@ const resClass = (i) => ({ :key="it.kind + it.id" class="ql-res" :class="resClass(i)" - @click="selected = i" - @dblclick="$emit('choose', i)" + :data-color="rowColorId(it.color)" + :style="colorStyle(it)" + @click="activate(i, $event)" > diff --git a/src/renderer/src/components/SavedDiffRow.vue b/src/renderer/src/components/SavedDiffRow.vue index 4ba55844..6c618a5c 100644 --- a/src/renderer/src/components/SavedDiffRow.vue +++ b/src/renderer/src/components/SavedDiffRow.vue @@ -19,6 +19,7 @@ import { shaped } from '../utils/props' import AppIcon from './AppIcon.vue' import { useShareStore } from '../features/share' import { useUiStore } from '../stores/uiStore' +import { useRowTags } from '../composables/useRowTags' const props = defineProps({ /** @type {import('vue').PropType} */ @@ -41,7 +42,10 @@ const SOON_MS = 15 * 60_000 const isNew = computed(() => ui.lastCreatedRowId === props.entry.id) const formatKey = computed(() => rowFormatKey(props.entry)) const mono = computed(() => languageMonogram(formatKey.value)) -const shownTags = computed(() => rowTags(props.entry)) +const { shown: shownTags, showing: showTags } = useRowTags(() => rowTags(props.entry)) +// With the words off the second line has nothing left to say unless the diff +// came from someone — an empty one holds its own height open. +const hasSubline = computed(() => showTags.value || !!props.entry.from) const tagColor = (t) => snippets.colorOf(t) || 'var(--text-dim)' const title = computed(() => { @@ -128,7 +132,7 @@ function onDragStart(e) { is information a ten-second-old snippet's age is not. --> {{ $t('newRow.badge') }} - +