diff --git a/marketing/cards/features.png b/marketing/cards/features.png new file mode 100644 index 00000000..fda22e95 Binary files /dev/null and b/marketing/cards/features.png differ diff --git a/marketing/cards/hero-og.png b/marketing/cards/hero-og.png new file mode 100644 index 00000000..ce1f2f91 Binary files /dev/null and b/marketing/cards/hero-og.png differ diff --git a/marketing/cards/privacy.png b/marketing/cards/privacy.png new file mode 100644 index 00000000..68d6a282 Binary files /dev/null and b/marketing/cards/privacy.png differ diff --git a/marketing/cards/src/card.css b/marketing/cards/src/card.css new file mode 100644 index 00000000..c105d721 --- /dev/null +++ b/marketing/cards/src/card.css @@ -0,0 +1,113 @@ +/* Shared brand tokens for Git Diff Review social cards. + Colors/fonts mirror the app theme (dunkel.json + style.css). */ + +@font-face { + font-family: "Fira Code"; + font-weight: 400; + font-display: block; + src: url("../../../packages/ui/src/fonts/firacode/FiraCode-Regular.woff2") format("woff2"); +} +@font-face { + font-family: "Fira Code"; + font-weight: 600; + font-display: block; + src: url("../../../packages/ui/src/fonts/firacode/FiraCode-SemiBold.woff2") format("woff2"); +} + +:root { + --bg: #242424; + --bg-2: #1c1c1c; + --panel: #2c2c2c; + --fg: #fafafa; + --muted: #a0a0a0; + --faint: #6b6b6b; + --accent: #89ddff; + --green: #5ad27e; + --red: #ff6762; + --border: #ffffff14; + --radius: 18px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + background: var(--bg); +} + +body { + font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif; + color: var(--fg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + overflow: hidden; +} + +.mono { + font-family: "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace; +} + +/* Card frame: each card sets data-w / data-h via inline width/height. */ +.card { + position: relative; + overflow: hidden; + background: + radial-gradient(120% 120% at 100% 0%, #2f2f2f 0%, var(--bg) 55%) , + var(--bg); +} + +/* Subtle diff-gutter motif used on several cards. */ +.gutter { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0.5; +} +.gutter span { + position: absolute; + left: 0; + width: 6px; + border-radius: 0 4px 4px 0; +} +.add { + color: var(--green); +} +.del { + color: var(--red); +} +.cyan { + color: var(--accent); +} +.muted { + color: var(--muted); +} + +.wordmark { + font-weight: 700; + letter-spacing: -0.02em; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 10px; + border: 1px solid var(--border); + background: #ffffff0a; + border-radius: 999px; + color: var(--muted); +} + +.dots { + display: inline-flex; + gap: 8px; +} +.dots i { + width: 13px; + height: 13px; + border-radius: 50%; + display: inline-block; +} diff --git a/marketing/cards/src/features.html b/marketing/cards/src/features.html new file mode 100644 index 00000000..723aa688 --- /dev/null +++ b/marketing/cards/src/features.html @@ -0,0 +1,136 @@ + + + + + + + + +
+
+ + Git Diff Review +
+

The pull-request review you already know — locally.

+
+
+
+
+
A real review surface
+
File list, status badges, +/− counts, jump between hunks.
+
+
+
+
±
+
+
Split or unified diffs
+
Syntax-highlighted, your choice per session.
+
+
+
+
+
+
Viewed-file tracking & inline notes
+
Tick files off (0/14 viewed), drop comments on lines.
+
+
+
+
+
+
Keyboard-driven
+
j/k navigate · v viewed · c comment · ⌘↵ submit
+
+
+
+
+
+
100% local
+
No cloud, no sign-up, no telemetry — stored in local SQLite.
+
+
+
+
github.com/oullin/git-diff · MIT · macOS (Apple Silicon)
+
+ + diff --git a/marketing/cards/src/hero-og.html b/marketing/cards/src/hero-og.html new file mode 100644 index 00000000..871d927c --- /dev/null +++ b/marketing/cards/src/hero-og.html @@ -0,0 +1,113 @@ + + + + + + + + +
+
+
+ + before you commit +
+

Git Diff
Review

+
+ Review your local Git changes like a pull request. + On your machine. Nothing leaves it. +
+
+ caught the stray debug log   + before it hit history +
+
+
+ github.com/oullin/git-diff + + MIT + macOS · Apple Silicon + +
+
+ + diff --git a/marketing/cards/src/privacy.html b/marketing/cards/src/privacy.html new file mode 100644 index 00000000..9318941f --- /dev/null +++ b/marketing/cards/src/privacy.html @@ -0,0 +1,121 @@ + + + + + + + + +
+
+ + Git Diff Review +
+

Your code never leaves your machine.

+
+ No cloud + No sign-up + No telemetry +
+
+
+ + + +
+
+ # reviews, comments & prefs live here — only here
+ ~/Library/Application Support/git-diff/reviews.sqlite3 +
+
+
Reads Git over a local Unix socket. Never pushes, never opens a PR. github.com/oullin/git-diff
+
+ + diff --git a/marketing/cards/src/screenshot.html b/marketing/cards/src/screenshot.html new file mode 100644 index 00000000..d4e98ba4 --- /dev/null +++ b/marketing/cards/src/screenshot.html @@ -0,0 +1,90 @@ + + + + + + + + +
+
+
A real PR review surface — for your local working tree.
+
+ + Git Diff Review +
+
+
+
+ + + +
+ +
+
+ + diff --git a/packages/ui/src/composables/useAppSession.ts b/packages/ui/src/composables/useAppSession.ts index 7caa4209..1e23b75a 100644 --- a/packages/ui/src/composables/useAppSession.ts +++ b/packages/ui/src/composables/useAppSession.ts @@ -45,9 +45,29 @@ export interface AppSessionOptions { } export function useAppSession(opts: AppSessionOptions) { - let unsubscribeShortcuts: (() => void) | null = null; let unsubscribeLaunchIntent: (() => void) | null = null; + // Attach keyboard shortcuts synchronously during setup so the listener is + // live regardless of auth-bootstrap outcome or timing. Wiring this after + // `await bootstrapAuth()` meant a throwing/hanging bootstrap left every + // shortcut dead. The composable registers its own onUnmounted cleanup. + useKeyboardShortcuts({ + enabled: opts.shortcutsEnabled, + onSelectAdjacent: opts.selectAdjacent, + onJumpToHunk: opts.jumpToHunk, + onToggleViewed: () => { + const file = opts.selectedFile.value; + + if (file && opts.changedByPath.value.has(file.path)) { + void opts.toggleViewed(file); + } + }, + onStartReview: () => void opts.startReview(), + onOpenSearch: () => { + opts.searchOpen.value = true; + }, + }); + async function bootstrapAuth() { const { entered } = await opts.bootstrapAuthStore(); @@ -129,22 +149,6 @@ export function useAppSession(opts: AppSessionOptions) { onMounted(async () => { await bootstrapAuth(); - unsubscribeShortcuts = useKeyboardShortcuts({ - enabled: opts.shortcutsEnabled, - onSelectAdjacent: opts.selectAdjacent, - onJumpToHunk: opts.jumpToHunk, - onToggleViewed: () => { - const file = opts.selectedFile.value; - - if (file && opts.changedByPath.value.has(file.path)) { - void opts.toggleViewed(file); - } - }, - onStartReview: () => void opts.startReview(), - onOpenSearch: () => { - opts.searchOpen.value = true; - }, - }); unsubscribeLaunchIntent = window.diffApp.onLaunchIntent(async (intent) => { if (intent.kind === 'help' || !intent.repoPath) { return; @@ -164,7 +168,6 @@ export function useAppSession(opts: AppSessionOptions) { }); onUnmounted(() => { - unsubscribeShortcuts?.(); unsubscribeLaunchIntent?.(); }); diff --git a/packages/ui/src/composables/useKeyboardShortcuts.ts b/packages/ui/src/composables/useKeyboardShortcuts.ts index 84fff605..b5650a30 100644 --- a/packages/ui/src/composables/useKeyboardShortcuts.ts +++ b/packages/ui/src/composables/useKeyboardShortcuts.ts @@ -89,6 +89,8 @@ export function useKeyboardShortcuts(opts: UseKeyboardShortcutsOptions): () => v if ((bindings.fileFilter && matchesBinding(event, bindings.fileFilter)) || (bindings.diffSearch && matchesBinding(event, bindings.diffSearch))) { event.preventDefault(); opts.onOpenSearch(); + + return; } } diff --git a/packages/ui/tests/useKeyboardShortcuts.test.ts b/packages/ui/tests/useKeyboardShortcuts.test.ts new file mode 100644 index 00000000..7bd80c73 --- /dev/null +++ b/packages/ui/tests/useKeyboardShortcuts.test.ts @@ -0,0 +1,96 @@ +import { mount } from '@vue/test-utils'; +import { createPinia } from 'pinia'; +import { ref, defineComponent, h, type Ref } from 'vue'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useKeyboardShortcuts, type UseKeyboardShortcutsOptions } from '@composables/useKeyboardShortcuts.js'; + +// Mounts a trivial host that wires the shortcuts in setup, mirroring how +// useAppSession attaches them synchronously during component setup. +function mountHost(overrides: Partial = {}, enabled: Ref = ref(true)) { + const opts: UseKeyboardShortcutsOptions = { + enabled, + onSelectAdjacent: vi.fn(), + onJumpToHunk: vi.fn(), + onToggleViewed: vi.fn(), + onStartReview: vi.fn(), + onOpenSearch: vi.fn(), + ...overrides, + }; + + const Host = defineComponent({ + setup() { + useKeyboardShortcuts(opts); + + return () => h('div'); + }, + }); + + const wrapper = mount(Host, { global: { plugins: [createPinia()] } }); + + return { wrapper, opts }; +} + +function press(key: string, init: KeyboardEventInit = {}): void { + document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })); +} + +describe('useKeyboardShortcuts', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('fires the navigation action for the default next_file binding', () => { + const { opts, wrapper } = mountHost(); + + press('j'); + + expect(opts.onSelectAdjacent).toHaveBeenCalledWith(1); + + wrapper.unmount(); + }); + + it('fires for ArrowDown / ArrowUp aliases', () => { + const { opts, wrapper } = mountHost(); + + press('ArrowDown'); + press('ArrowUp'); + + expect(opts.onSelectAdjacent).toHaveBeenNthCalledWith(1, 1); + expect(opts.onSelectAdjacent).toHaveBeenNthCalledWith(2, -1); + + wrapper.unmount(); + }); + + it('does nothing when disabled', () => { + const enabled = ref(false); + const { opts, wrapper } = mountHost({}, enabled); + + press('j'); + + expect(opts.onSelectAdjacent).not.toHaveBeenCalled(); + + wrapper.unmount(); + }); + + it('ignores keystrokes typed into inputs', () => { + const { opts, wrapper } = mountHost(); + const input = document.createElement('input'); + + document.body.appendChild(input); + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', bubbles: true })); + + expect(opts.onSelectAdjacent).not.toHaveBeenCalled(); + + wrapper.unmount(); + }); + + it('detaches the listener on unmount', () => { + const { opts, wrapper } = mountHost(); + + wrapper.unmount(); + press('j'); + + expect(opts.onSelectAdjacent).not.toHaveBeenCalled(); + }); +});