From 020b8f00caad1e17780bfea8168a1debb34e7a84 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 11 May 2026 11:14:48 +0200 Subject: [PATCH] Add pre-commit verification hook --- .githooks/pre-commit | 5 ++++ package.json | 3 ++- scripts/install-git-hooks.mjs | 14 ++++++++++ src/client/src/components/PiWebApp.ts | 10 +++---- .../src/components/selectableRow.test.ts | 26 ++++++++++++------- src/client/src/components/selectableRow.ts | 19 +++++++++----- 6 files changed, 55 insertions(+), 22 deletions(-) create mode 100755 .githooks/pre-commit create mode 100644 scripts/install-git-hooks.mjs diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..8cc1b15 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +set -eu + +echo "Running pre-commit checks: npm run verify" +npm run verify diff --git a/package.json b/package.json index 111ee09..e2621fa 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "prepack": "npm run build", "pack:dry": "npm pack --dry-run", "prepublishOnly": "npm run verify", - "publish:npm": "npm publish --access public" + "publish:npm": "npm publish --access public", + "prepare": "node scripts/install-git-hooks.mjs" }, "dependencies": { "@codemirror/lang-css": "^6.3.1", diff --git a/scripts/install-git-hooks.mjs b/scripts/install-git-hooks.mjs new file mode 100644 index 0000000..25c3007 --- /dev/null +++ b/scripts/install-git-hooks.mjs @@ -0,0 +1,14 @@ +import { existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; + +if (!existsSync('.git')) { + process.exit(0); +} + +try { + execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { stdio: 'inherit' }); + console.log('Configured git hooks path: .githooks'); +} catch (error) { + console.warn('Could not configure git hooks path. Run: git config core.hooksPath .githooks'); + process.exitCode = 0; +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index bcdf155..9c975a3 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -282,11 +282,11 @@ export class PiWebApp extends LitElement { selectedStagedDiff: this.state.selectedStagedDiff, gitStale: this.state.gitStale, activeTerminalCount: this.state.activeTerminalCount, - onRefreshFiles: () => this.files.refreshFiles(), - onExpandDir: (path: string) => this.files.expandDir(path), - onSelectFile: (path: string) => this.files.selectFile(path), - onRefreshGit: () => this.git.refreshGit(), - onSelectDiff: (path: string) => this.git.selectDiff(path), + onRefreshFiles: () => { void this.files.refreshFiles(); }, + onExpandDir: (path: string) => { void this.files.expandDir(path); }, + onSelectFile: (path: string) => { void this.files.selectFile(path); }, + onRefreshGit: () => { void this.git.refreshGit(); }, + onSelectDiff: (path: string) => { void this.git.selectDiff(path); }, }; } diff --git a/src/client/src/components/selectableRow.test.ts b/src/client/src/components/selectableRow.test.ts index cdfa927..e2973b8 100644 --- a/src/client/src/components/selectableRow.test.ts +++ b/src/client/src/components/selectableRow.test.ts @@ -4,21 +4,21 @@ import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./sele describe("selectable row activation", () => { it("activates rows from non-interactive click targets", () => { const action = vi.fn(); - activateSelectableRow(eventWithPath({ matches: () => false }), action); + activateSelectableRow(eventWithPath(matchTarget(() => false)), action); expect(action).toHaveBeenCalledOnce(); }); it("preserves contributed links and other interactive elements", () => { const action = vi.fn(); - activateSelectableRow(eventWithPath({ matches: (selector: string) => selector.includes("a[href]") }), action); + activateSelectableRow(eventWithPath(matchTarget((selector: string) => selector.includes("a[href]"))), action); expect(action).not.toHaveBeenCalled(); }); it("activates rows from Enter and Space", () => { const enterAction = vi.fn(); const spaceAction = vi.fn(); - const enter = keyboardEventWithPath("Enter", { matches: () => false }); - const space = keyboardEventWithPath(" ", { matches: () => false }); + const enter = keyboardEventWithPath("Enter", matchTarget(() => false)); + const space = keyboardEventWithPath(" ", matchTarget(() => false)); activateSelectableRowFromKeyboard(enter, enterAction); activateSelectableRowFromKeyboard(space, spaceAction); @@ -31,7 +31,7 @@ describe("selectable row activation", () => { it("does not activate rows from keyboard events inside interactive elements", () => { const action = vi.fn(); - const event = keyboardEventWithPath("Enter", { matches: (selector: string) => selector.includes("button") }); + const event = keyboardEventWithPath("Enter", matchTarget((selector: string) => selector.includes("button"))); activateSelectableRowFromKeyboard(event, action); @@ -40,10 +40,18 @@ describe("selectable row activation", () => { }); }); -function eventWithPath(target: Pick): MouseEvent { - return { composedPath: () => [target] } as unknown as MouseEvent; +type EventWithPath = Pick; +type KeyboardEventWithPath = EventWithPath & Pick; +type MatchTarget = EventTarget & Pick; + +function matchTarget(matches: Element["matches"]): MatchTarget { + return Object.assign(new EventTarget(), { matches }); } -function keyboardEventWithPath(key: string, target: Pick): KeyboardEvent & { preventDefault: ReturnType } { - return { key, preventDefault: vi.fn(), composedPath: () => [target] } as unknown as KeyboardEvent & { preventDefault: ReturnType }; +function eventWithPath(target: MatchTarget): EventWithPath { + return { composedPath: () => [target] }; +} + +function keyboardEventWithPath(key: string, target: MatchTarget): KeyboardEventWithPath { + return { key, preventDefault: vi.fn<() => void>(), composedPath: () => [target] }; } diff --git a/src/client/src/components/selectableRow.ts b/src/client/src/components/selectableRow.ts index 9f7c9b8..7847ab8 100644 --- a/src/client/src/components/selectableRow.ts +++ b/src/client/src/components/selectableRow.ts @@ -10,21 +10,26 @@ const interactiveSelector = [ "[contenteditable='true']", ].join(","); -export function isFromInteractiveElement(event: Event): boolean { - return event.composedPath().some((target) => isElementLike(target) && target.matches(interactiveSelector)); +type ComposedPathEvent = Pick; +type SelectableKeyboardEvent = ComposedPathEvent & Pick; + +export function isFromInteractiveElement(event: ComposedPathEvent): boolean { + return event.composedPath().some((target) => targetMatches(target, interactiveSelector)); } -function isElementLike(target: EventTarget): target is Element { - if (typeof Element !== "undefined") return target instanceof Element; - return typeof (target as Partial).matches === "function"; +function targetMatches(target: EventTarget, selector: string): boolean { + if (typeof Element !== "undefined" && target instanceof Element) return target.matches(selector); + if (!("matches" in target)) return false; + const { matches } = target; + return typeof matches === "function" && matches.call(target, selector) === true; } -export function activateSelectableRow(event: MouseEvent, action: () => void): void { +export function activateSelectableRow(event: ComposedPathEvent, action: () => void): void { if (isFromInteractiveElement(event)) return; action(); } -export function activateSelectableRowFromKeyboard(event: KeyboardEvent, action: () => void): void { +export function activateSelectableRowFromKeyboard(event: SelectableKeyboardEvent, action: () => void): void { if (event.key !== "Enter" && event.key !== " ") return; if (isFromInteractiveElement(event)) return; event.preventDefault();