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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@

## Unreleased

## 0.5.0 - 2026-10-08

### Added

- Show the installed version and an About tab in help, with builder credits and a clearly marked updates placeholder.
- Open the Ruby Elders website from the About tab.
- Build and run on Windows with a native-style title bar that holds the review controls, like the GTK header bar.

### Changed

- Split Navigation shortcuts across two columns and separate alternative shortcuts with "or".
- Keep the help dialog size and edge spacing consistent when switching tabs.

### Fixed

- Copy non-ASCII review text to the Windows clipboard without corrupting it.
Expand Down
22 changes: 22 additions & 0 deletions e2e/specs/review.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,28 @@ describe("grr review window", () => {
&& Boolean(document.querySelector<HTMLImageElement>("section[aria-label='Keyboard shortcuts'] img[alt='grr']")?.complete)
))).toBe(true);
writeFileSync("e2e-results/keyboard-shortcuts.png", Buffer.from(await browser.takeScreenshot(), "base64"));
const helpSize = await browser.execute(() => {
const rect = document.querySelector("section[role='dialog'][aria-label='Keyboard shortcuts']")!.getBoundingClientRect();
return { width: rect.width, height: rect.height };
});
await clickElement("#help-tab-about");
await browser.waitUntil(async () => await browser.execute(
() => document.querySelector("#help-tab-about")?.getAttribute("aria-selected") === "true",
), { timeout: 5_000, interval: 50, timeoutMsg: "About tab did not open" });
const about = await browser.execute(() => {
const dialog = document.querySelector("section[role='dialog'][aria-label='Keyboard shortcuts']")!;
const panel = document.querySelector<HTMLElement>("#help-panel-about")!;
const rect = dialog.getBoundingClientRect();
return {
width: rect.width, height: rect.height, visible: !panel.hidden,
version: dialog.querySelector("h2")?.textContent,
updateDisabled: panel.querySelector<HTMLButtonElement>("button")?.disabled,
website: panel.querySelector("a")?.getAttribute("href"),
};
});
expect(about).toMatchObject({ ...helpSize, visible: true, updateDisabled: true, website: "https://rubyelders.com" });
expect(about.version).toMatch(/^grr v\d+\.\d+\.\d+$/);
writeFileSync("e2e-results/about.png", Buffer.from(await browser.takeScreenshot(), "base64"));
await browser.execute(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
});
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "grr-ui",
"private": true,
"version": "0.4.0",
"version": "0.5.0",
"type": "module",
"engines": {
"node": ">=22.20.0 <23"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "grr"
version = "0.4.0"
version = "0.5.0"
edition = "2024"
rust-version = "1.90"
publish = false
Expand Down
28 changes: 28 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,33 @@ fn cancel_review(window: Window) -> Result<(), String> {
window.close().map_err(|error| error.to_string())
}

#[tauri::command]
async fn open_author_website() -> Result<(), String> {
tauri::async_runtime::spawn_blocking(|| {
#[cfg(target_os = "macos")]
let mut command = std::process::Command::new("open");
#[cfg(target_os = "linux")]
let mut command = std::process::Command::new("xdg-open");
#[cfg(target_os = "windows")]
let mut command = {
let mut command = std::process::Command::new("rundll32");
command.arg("url.dll,FileProtocolHandler");
command
};
let status = command
.arg("https://rubyelders.com")
.status()
.map_err(|error| format!("could not open browser: {error}"))?;
if status.success() {
Ok(())
} else {
Err(format!("browser launcher exited with {status}"))
}
})
.await
.map_err(|error| format!("browser task failed: {error}"))?
}

fn main() {
let options = match parse_args() {
Ok(Some(options)) => options,
Expand Down Expand Up @@ -123,6 +150,7 @@ fn main() {
select_commits,
finish_review,
cancel_review,
open_author_website,
get_window_chrome,
show_window_menu,
update_window_chrome
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "grr",
"version": "0.4.0",
"version": "0.5.0",
"identifier": "com.rubyelders.grr",
"build": {
"beforeDevCommand": "npm run dev",
Expand Down
37 changes: 32 additions & 5 deletions ui/src/components/ShortcutHelp.module.css
Original file line number Diff line number Diff line change
@@ -1,19 +1,46 @@
.backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 24px; background: rgba(31, 35, 40, .45); }
.modal { width: min(680px, 100%); max-height: min(720px, calc(100vh - 48px)); overflow: auto; border: 1px solid var(--border); border-radius: 12px; background: var(--bg); box-shadow: 0 16px 48px rgba(27, 31, 36, .28); }
.header { position: sticky; top: 0; z-index: 1; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 20px 22px 16px; border-bottom: 1px solid var(--border); background: var(--bg); }
.modal { --dialog-inset: 24px; display: flex; flex-direction: column; width: min(680px, 100%); height: min(720px, calc(100vh - 48px)); overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--bg); box-shadow: 0 16px 48px rgba(27, 31, 36, .28); }
.header { flex: none; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 20px var(--dialog-inset) 16px; border-bottom: 1px solid var(--border); background: var(--bg); }
.header h2 { margin: 0; font-size: 20px; }
.header p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }
.header p { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: 13px; }
.header button { width: 30px; height: 30px; display: grid; flex: none; place-items: center; padding: 0; border: 1px solid var(--border); border-radius: 6px; background: var(--canvas); cursor: pointer; }
.heading { display: flex; align-items: center; gap: 12px; }
.heading { display: grid; grid-template-columns: 42px 1fr; align-items: center; gap: 8px 12px; }
.heading img { width: 42px; height: 42px; }
.groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px 30px; padding: 20px 22px 24px; }
.groups { flex: 1; min-height: 0; overflow: auto; display: grid; align-content: start; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px 30px; padding: 20px var(--dialog-inset) 24px; }
.shortcutHint { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.alternative { display: inline-flex; align-items: center; gap: 6px; }
.or { color: var(--muted); font-size: 11px; }
.group h3 { margin: 0 0 8px; color: var(--muted); font-size: 12px; letter-spacing: .04em; text-transform: uppercase; }
.group dl { margin: 0; }
.navigation { grid-column: 1 / -1; order: 1; }
.window { grid-column: 1 / -1; order: 2; }
.navigation dl, .window dl { column-count: 2; column-gap: 30px; }
.navigation .row, .window .row { break-inside: avoid; }
.row { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; padding: 9px 0 10px; border-top: 1px solid var(--subtle-border); }
.row dt { line-height: 1.3; }
.row dd { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; }
.row kbd { padding: 3px 7px; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; color: var(--fg); background: var(--canvas); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; white-space: nowrap; }

@media (max-width: 680px) {
.groups { grid-template-columns: 1fr; }
.navigation dl, .window dl { column-count: 1; }
}

.version { display: inline-block; margin-left: 6px; vertical-align: middle; padding: 3px 7px; border: 1px solid var(--border); border-radius: 5px; color: var(--muted); font-size: 12px; font-weight: 500; }
.tabs { flex: none; display: flex; gap: 24px; padding: 0 var(--dialog-inset); border-bottom: 1px solid var(--border); }
.tabs button { padding: 14px 0 12px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); font: inherit; font-weight: 600; cursor: pointer; }
.tabs button[aria-selected="true"] { color: var(--fg); border-bottom-color: var(--blue); font-weight: 600; }
.groups[hidden], .about[hidden] { display: none; }
.about { flex: 1; min-height: 0; overflow: auto; padding: 20px var(--dialog-inset) 24px; }
.releaseCard { padding: 26px; border: 1px solid var(--border); border-top: 3px solid var(--blue); border-radius: 9px; background: var(--canvas); }
.cardHeading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.cardHeading h3 { margin: 0; font-size: 14px; }
.previewBadge { padding: 3px 7px; border: 1px solid var(--border); border-radius: 12px; color: var(--muted); font-size: 11px; }
.releaseCard p { color: var(--muted); line-height: 1.6; margin: 10px 0 18px; }
.updateRow { display: flex; justify-content: space-between; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; }
.releaseCard button { margin-top: 16px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--muted); font: inherit; font-size: 12px; }
.releaseCard small { display: block; margin-top: 8px; color: var(--muted); font-size: 11px; }
.facts { display: flex; justify-content: space-between; gap: 16px; margin-top: 22px; font-size: 11px; color: var(--muted); }
.facts strong { display: block; margin-top: 5px; font-weight: 500; color: var(--fg); }
.facts a { color: var(--blue); text-underline-offset: 3px; }
.linkError { color: var(--red); font-size: 12px; }
69 changes: 67 additions & 2 deletions ui/src/components/ShortcutHelp.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { render, screen, within } from "@testing-library/preact";
import { render, screen, waitFor, within } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { invoke, isTauri } = vi.hoisted(() => ({ invoke: vi.fn(), isTauri: vi.fn() }));
vi.mock("@tauri-apps/api/core", () => ({ invoke, isTauri }));

import { SHORTCUTS, shortcutChords } from "../shortcuts";
import { ShortcutHelp } from "./ShortcutHelp";

describe("ShortcutHelp", () => {
beforeEach(() => {
invoke.mockReset();
isTauri.mockReturnValue(false);
});
it("shows native Mac notation with readable accessibility labels", () => {
const platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
try {
Expand All @@ -29,6 +36,64 @@ describe("ShortcutHelp", () => {
}
});

it("switches About and shortcuts with clicks and arrow keys", async () => {
const user = userEvent.setup();
render(<ShortcutHelp onClose={vi.fn()} />);
const about = screen.getByRole("tab", { name: "About grr" });
await user.click(about);
expect(about).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel", { name: "About grr" })).toBeVisible();
expect(screen.getByRole("button", { name: "Check for updates" })).toBeDisabled();
await user.keyboard("{ArrowLeft}");
expect(screen.getByRole("tab", { name: "Keyboard shortcuts" })).toHaveFocus();
expect(screen.getByRole("tabpanel", { name: "Keyboard shortcuts" })).toBeVisible();
await user.keyboard("{ArrowRight}");
expect(about).toHaveFocus();
await user.keyboard("{Home}");
expect(screen.getByRole("tab", { name: "Keyboard shortcuts" })).toHaveFocus();
await user.keyboard("{End}");
expect(about).toHaveFocus();
expect(about).toHaveAttribute("aria-selected", "true");
});

it("separates alternatives from keys pressed together", () => {
render(<ShortcutHelp onClose={vi.fn()} />);
const row = screen.getByText("Show the newer commit or virtual commit").parentElement!;
expect(within(row).getByText("or")).toBeInTheDocument();
expect(row.querySelectorAll("kbd")).toHaveLength(2);
const combined = screen.getByText("Approve or share and copy the result").parentElement!;
expect(combined.querySelectorAll("kbd")).toHaveLength(1);
expect(within(combined).queryByText("or")).not.toBeInTheDocument();
});

it("shows the update stub and builder website", async () => {
const user = userEvent.setup();
render(<ShortcutHelp onClose={vi.fn()} />);
expect(screen.getByRole("heading", { name: /grr v/ })).toBeInTheDocument();
expect(screen.getByText("Git Robust Review")).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "About grr" }));
expect(screen.getByText("Not available yet")).toBeInTheDocument();
expect(screen.getByText("Update checking is not available in this version.")).toBeInTheDocument();
const link = screen.getByRole("link", { name: "Ruby Elders" });
expect(link).toHaveAttribute("href", "https://rubyelders.com");
await user.click(link);
expect(invoke).not.toHaveBeenCalled();
});

it("opens the builder website through the desktop command and reports failures", async () => {
isTauri.mockReturnValue(true);
invoke.mockRejectedValueOnce(new Error("no browser")).mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<ShortcutHelp onClose={vi.fn()} />);
await user.click(screen.getByRole("tab", { name: "About grr" }));
const link = screen.getByRole("link", { name: "Ruby Elders" });
await user.click(link);
expect(invoke).toHaveBeenCalledWith("open_author_website");
expect(await screen.findByRole("alert")).toHaveTextContent("Could not open the browser");
await user.click(link);
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
});

it("focuses and activates its close button", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
Expand Down
41 changes: 36 additions & 5 deletions ui/src/components/ShortcutHelp.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { useEffect, useRef } from "preact/hooks";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { useEffect, useRef, useState } from "preact/hooks";
import { SHORTCUT_CATEGORIES, SHORTCUTS, shortcutChords } from "../shortcuts";
import { Icon } from "./Icon";
import logoUrl from "../../../src-tauri/icons/icon.png";
import { version } from "../../../package.json";
import styles from "./ShortcutHelp.module.css";

export function ShortcutHelp({ onClose }: { onClose(): void }) {
const [tab, setTab] = useState<"shortcuts" | "about">("shortcuts");
const [linkError, setLinkError] = useState(false);
const close = useRef<HTMLButtonElement>(null);
useEffect(() => close.current?.focus(), []);

Expand All @@ -14,25 +18,52 @@ export function ShortcutHelp({ onClose }: { onClose(): void }) {
<header class={styles.header}>
<div class={styles.heading}>
<img src={logoUrl} alt="grr" />
<div><h2>Keyboard shortcuts</h2><p>grr - local Git review</p></div>
<h2>grr <span class={styles.version}>v{version}</span></h2><p>Git Robust Review</p>
</div>
<button ref={close} type="button" aria-label="Close keyboard shortcuts" onClick={onClose}><Icon name="close" /></button>
</header>
<div class={styles.groups}>
<div class={styles.tabs} role="tablist" aria-label="Help">
{(["shortcuts", "about"] as const).map((item) => <button type="button" role="tab" id={`help-tab-${item}`} aria-controls={`help-panel-${item}`} aria-selected={tab === item} tabIndex={tab === item ? 0 : -1} onClick={() => setTab(item)} onKeyDown={(event) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? "shortcuts" : event.key === "End" ? "about" : tab === "about" ? "shortcuts" : "about";
setTab(next);
document.getElementById(`help-tab-${next}`)?.focus();
}
}}>{item === "shortcuts" ? "Keyboard shortcuts" : "About grr"}</button>)}
</div>
<div class={styles.groups} role="tabpanel" id="help-panel-shortcuts" aria-labelledby="help-tab-shortcuts" tabIndex={0} hidden={tab !== "shortcuts"}>
<p class={styles.shortcutHint}>Keys in one badge are pressed together. "or" separates alternative shortcuts.</p>
{SHORTCUT_CATEGORIES.map((category) => (
<section class={styles.group} key={category} aria-labelledby={`shortcut-${category}`}>
<section class={`${styles.group} ${category === "Navigation" ? styles.navigation : category === "Window" ? styles.window : ""}`} key={category} aria-labelledby={`shortcut-${category}`}>
<h3 id={`shortcut-${category}`}>{category}</h3>
<dl>
{SHORTCUTS.filter((shortcut) => shortcut.category === category).map((shortcut) => (
<div class={styles.row} key={shortcut.id}>
<dt>{shortcut.description}</dt>
<dd>{shortcutChords(shortcut.id).map((item) => <kbd key={item.id} aria-label={item.label}>{item.display}</kbd>)}</dd>
<dd>{shortcutChords(shortcut.id).map((item, index) => <span class={styles.alternative} key={item.id}>{index > 0 ? <span class={styles.or}>or</span> : null}<kbd aria-label={item.label}>{item.display}</kbd></span>)}</dd>
</div>
))}
</dl>
</section>
))}
</div>
<div class={styles.about} role="tabpanel" id="help-panel-about" aria-labelledby="help-tab-about" tabIndex={0} hidden={tab !== "about"}>
<section class={styles.releaseCard}>
<div class={styles.cardHeading}><h3>Releases & updates</h3><span class={styles.previewBadge}>Not available yet</span></div>
<p>Release news and upgrade options will appear here.</p>
<div class={styles.updateRow}><span>Installed version</span><strong>{version}</strong></div>
<button type="button" disabled>Check for updates</button>
<small>Update checking is not available in this version.</small>
</section>
<div class={styles.facts}><span>Built with <strong>Rust + Tauri + Preact</strong></span><span>Built by <strong><a href="https://rubyelders.com" target="_blank" rel="noopener noreferrer" onClick={(event) => {
if (!isTauri()) return;
event.preventDefault();
setLinkError(false);
void invoke("open_author_website").catch(() => setLinkError(true));
}}>Ruby Elders</a></strong></span></div>
{linkError ? <p class={styles.linkError} role="alert">Could not open the browser. Visit rubyelders.com.</p> : null}
</div>
</section>
</div>
);
Expand Down
Loading