From ee31e08ab958b91df31418071462cd49f0e8346d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 25 Sep 2026 13:27:16 +0200 Subject: [PATCH 1/6] Add a shared fuzzy matcher for the command palette fzf-v1 style subsequence scoring (word-boundary, camelCase and run bonuses, gap penalties) with a file-name-first path variant and a bounded top-K collector. Lives in shared so main (file index) and renderer (commands, refs) rank and highlight identically. --- src/shared/fuzzy.test.ts | 113 +++++++++++++++++++++ src/shared/fuzzy.ts | 205 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 src/shared/fuzzy.test.ts create mode 100644 src/shared/fuzzy.ts diff --git a/src/shared/fuzzy.test.ts b/src/shared/fuzzy.test.ts new file mode 100644 index 0000000..18d5bc8 --- /dev/null +++ b/src/shared/fuzzy.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'bun:test' +import { + foldCase, + fuzzyMatch, + fuzzyMatchPath, + fuzzyScore, + fuzzyScorePath, + normalizeQuery, + TopMatches +} from './fuzzy' + +describe('normalizeQuery', () => { + it('folds case and drops whitespace', () => { + expect(normalizeQuery(' New Branch ')).toBe('newbranch') + }) +}) + +describe('foldCase', () => { + it('keeps the length even for code points that lowercase to two units', () => { + const text = 'İstanbul' + expect(foldCase(text)).toHaveLength(text.length) + expect(foldCase(text).slice(1)).toBe('stanbul') + }) +}) + +describe('fuzzyMatch', () => { + it('matches an in-order subsequence, case-insensitively', () => { + expect(fuzzyMatch('nb', 'New Branch')?.positions).toEqual([0, 4]) + }) + + it('rejects out-of-order and missing characters', () => { + expect(fuzzyMatch('bw', 'New Branch')).toBeNull() + expect(fuzzyMatch('xyz', 'New Branch')).toBeNull() + expect(fuzzyMatch('toolongquery', 'short')).toBeNull() + }) + + it('treats an empty query as a zero-score match', () => { + expect(fuzzyMatch('', 'anything')).toEqual({ score: 0, positions: [] }) + }) + + it('tightens a false start to the shortest window', () => { + // The first "a" leads nowhere closer; the match should hug "ab". + expect(fuzzyMatch('ab', 'a---ab')?.positions).toEqual([4, 5]) + }) + + it('ranks word-start matches above mid-word ones', () => { + const boundary = fuzzyScore('fb', 'feature/bar') + const midWord = fuzzyScore('fb', 'sofabed') + expect(boundary).not.toBeNull() + expect(midWord).not.toBeNull() + expect(boundary!).toBeGreaterThan(midWord!) + }) + + it('ranks camelCase humps like word starts', () => { + expect(fuzzyScore('gv', 'GraphView')!).toBeGreaterThan(fuzzyScore('gv', 'grave')!) + }) + + it('ranks consecutive runs above scattered matches', () => { + expect(fuzzyScore('push', 'Push')!).toBeGreaterThan(fuzzyScore('push', 'Pull Such')!) + }) + + it('scores exactly what fuzzyScore reports', () => { + expect(fuzzyMatch('fetch', 'Fetch All')?.score).toBe(fuzzyScore('fetch', 'Fetch All')!) + }) +}) + +describe('fuzzyMatchPath', () => { + it('prefers a match inside the file name', () => { + const match = fuzzyMatchPath('view', 'src/view/old/GraphView.tsx') + // "view" lands on the basename's "View", not the directory. + expect(match?.positions).toEqual([18, 19, 20, 21]) + }) + + it('ranks a basename hit above a directory-spanning one', () => { + const inName = fuzzyScorePath('graph', 'src/components/GraphView.tsx')! + const acrossDirs = fuzzyScorePath('graph', 'g/r/a/p/h.ts')! + expect(inName).toBeGreaterThan(acrossDirs) + }) + + it('falls back to the whole path when the name alone cannot match', () => { + const match = fuzzyMatchPath('comp/gv', 'src/components/GraphView.tsx') + expect(match).not.toBeNull() + expect(match!.positions[0]).toBe(4) + }) + + it('matches root-level files by name', () => { + expect(fuzzyMatchPath('read', 'README.md')?.positions).toEqual([0, 1, 2, 3]) + }) + + it('scores exactly what fuzzyScorePath reports', () => { + const path = 'src/renderer/App.tsx' + expect(fuzzyMatchPath('app', path)?.score).toBe(fuzzyScorePath('app', path)!) + }) +}) + +describe('TopMatches', () => { + it('keeps the best items, best first', () => { + const top = new TopMatches(2) + top.add('low', 1, 3) + top.add('high', 9, 4) + top.add('mid', 5, 3) + expect(top.items()).toEqual(['high', 'mid']) + expect(top.total).toBe(3) + }) + + it('breaks ties by shorter text, then arrival order', () => { + const top = new TopMatches(3) + top.add('long-first', 5, 10) + top.add('short', 5, 5) + top.add('long-second', 5, 10) + expect(top.items()).toEqual(['short', 'long-first', 'long-second']) + }) +}) diff --git a/src/shared/fuzzy.ts b/src/shared/fuzzy.ts new file mode 100644 index 0000000..c6a0b6f --- /dev/null +++ b/src/shared/fuzzy.ts @@ -0,0 +1,205 @@ +// Fuzzy matching for the command palette: "does every query character appear, +// in order, somewhere in the text — and how good a match is it?". Shared by +// the main process (the repo's file index, up to hundreds of thousands of +// paths per keystroke) and the renderer (commands, refs, stashes), so the two +// rank and highlight identically. +// +// The algorithm is fzf's v1: a forward scan finds the first place the whole +// query fits, a backward scan from there tightens the window to the shortest +// tail, and a scoring pass over that window rewards matches on word +// boundaries (after `/`, `-`, `_`, `.`, a space, or a camelCase hump) and +// consecutive runs while penalizing gaps. Linear in the text length and +// allocation-free unless match positions are asked for — what keeps a +// single-letter query over a 500k-path repo within a frame budget. + +const SCORE_MATCH = 16 +const GAP_START = -3 +const GAP_EXTENSION = -1 +const BONUS_BOUNDARY = 8 +const BONUS_CAMEL = 7 +const BONUS_CONSECUTIVE = 5 +const FIRST_CHAR_MULTIPLIER = 2 +/** Matching inside a path's file name beats matching across its directories: + * `graph` should rank `GraphView.tsx` above `graphics/old/view.ts`. */ +const BONUS_BASENAME = 40 + +export interface FuzzyMatch { + score: number + /** Indexes into the text of each matched character, ascending. */ + positions: number[] +} + +/** + * Lowercase `text` without changing its length, so indexes into the folded + * copy are indexes into the original. `toLowerCase` can expand a few code + * points (`İ` → `i̇`); those fall back to a per-unit fold. + */ +export function foldCase(text: string): string { + const lower = text.toLowerCase() + if (lower.length === text.length) return lower + let out = '' + for (const unit of text) out += unit.toLowerCase().slice(0, unit.length).padEnd(unit.length) + return out +} + +/** The query as the matcher wants it: case-folded, whitespace ignored. */ +export function normalizeQuery(query: string): string { + return foldCase(query.replace(/\s+/g, '')) +} + +const isSeparator = (c: string) => + c === '/' || c === '\\' || c === '-' || c === '_' || c === '.' || c === ' ' || c === ':' +const isLower = (c: string) => c >= 'a' && c <= 'z' +const isUpper = (c: string) => c >= 'A' && c <= 'Z' + +/** How much a match at `i` is worth beyond the base score: word starts win. */ +function boundaryBonus(text: string, i: number): number { + if (i === 0) return BONUS_BOUNDARY + const prev = text[i - 1] + if (isSeparator(prev)) return BONUS_BOUNDARY + if (isLower(prev) && isUpper(text[i])) return BONUS_CAMEL + return 0 +} + +/** + * Score `query` (normalized) against `text` (with its `folded` twin), or null + * when it doesn't match. When `positions` is given, the matched indexes are + * appended to it. + */ +function scoreWindow( + query: string, + text: string, + folded: string, + positions: number[] | null +): number | null { + if (query.length === 0) return 0 + if (query.length > folded.length) return null + + // Forward: the earliest index at which the whole query has been seen. + let qi = 0 + let end = -1 + for (let i = 0; i < folded.length; i++) { + if (folded[i] === query[qi] && ++qi === query.length) { + end = i + break + } + } + if (end < 0) return null + + // Backward from there: the latest start that still fits the query, which + // trims a leading false start ("a…a…b" matching "ab" starts at the 2nd a). + let start = end + qi = query.length - 1 + for (let i = end; i >= 0; i--) { + if (folded[i] === query[qi] && --qi < 0) { + start = i + break + } + } + + let score = 0 + let inGap = false + let chunkBonus = 0 + let lastMatch = -2 + qi = 0 + for (let i = start; i <= end && qi < query.length; i++) { + if (folded[i] !== query[qi]) { + if (qi > 0) score += inGap ? GAP_EXTENSION : GAP_START + inGap = true + continue + } + let bonus = boundaryBonus(text, i) + // A consecutive run keeps the bonus of the word start it began on, so + // `view` fully inside `GraphView` scores like the hump it starts at. + if (i === lastMatch + 1) bonus = Math.max(bonus, chunkBonus, BONUS_CONSECUTIVE) + chunkBonus = bonus + score += SCORE_MATCH + (qi === 0 ? bonus * FIRST_CHAR_MULTIPLIER : bonus) + positions?.push(i) + lastMatch = i + inGap = false + qi++ + } + return score +} + +/** Fuzzy-match free text (commands, branch names, …). */ +export function fuzzyMatch(query: string, text: string, folded = foldCase(text)): FuzzyMatch | null { + const positions: number[] = [] + const score = scoreWindow(query, text, folded, positions) + return score === null ? null : { score, positions } +} + +/** Score-only {@link fuzzyMatch}: no allocation, for ranking huge lists. */ +export function fuzzyScore(query: string, text: string, folded = foldCase(text)): number | null { + return scoreWindow(query, text, folded, null) +} + +/** + * Fuzzy-match a `/`-separated path, preferring a match inside the file name + * (with a bonus) and falling back to the whole path — so `graphview` finds + * `src/components/graph/GraphView.tsx` by its name, while `comp/graph` + * still reaches across directories. + */ +export function fuzzyMatchPath( + query: string, + path: string, + folded = foldCase(path) +): FuzzyMatch | null { + const base = path.lastIndexOf('/') + 1 + if (base > 0) { + const positions: number[] = [] + const score = scoreWindow(query, path.slice(base), folded.slice(base), positions) + if (score !== null) { + return { score: score + BONUS_BASENAME, positions: positions.map((p) => p + base) } + } + } + return fuzzyMatch(query, path, folded) +} + +/** Score-only {@link fuzzyMatchPath}. */ +export function fuzzyScorePath(query: string, path: string, folded = foldCase(path)): number | null { + const base = path.lastIndexOf('/') + 1 + if (base > 0) { + const score = scoreWindow(query, path.slice(base), folded.slice(base), null) + if (score !== null) return score + BONUS_BASENAME + } + return scoreWindow(query, path, folded, null) +} + +/** + * Keeps the best `limit` of a stream of scored items without sorting (or + * even holding) the rest: higher score first, then the shorter text, then + * arrival order. `total` counts every item offered, for "N more" hints. + */ +export class TopMatches { + private readonly entries: { item: T; score: number; length: number }[] = [] + total = 0 + + constructor(private readonly limit: number) {} + + add(item: T, score: number, length: number): void { + this.total++ + const entries = this.entries + if (entries.length === this.limit && !ranksBefore(score, length, entries[entries.length - 1])) { + return + } + // Binary search for the insertion point; ties keep arrival order. + let lo = 0 + let hi = entries.length + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (ranksBefore(score, length, entries[mid])) hi = mid + else lo = mid + 1 + } + entries.splice(lo, 0, { item, score, length }) + if (entries.length > this.limit) entries.pop() + } + + items(): T[] { + return this.entries.map((e) => e.item) + } +} + +function ranksBefore(score: number, length: number, other: { score: number; length: number }) { + return score > other.score || (score === other.score && length < other.length) +} From 0b8ca7b0ef09106ec82e6dd7e9c340fad87d508f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 25 Sep 2026 13:33:05 +0200 Subject: [PATCH 2/6] Route menu commands through a shared registry; add palette search reads - shared/commands.ts: one list of named app commands (title, shortcut, repo gate). The native menu builds its items from it; the renderer's useAppCommands runs them, for the menu today and the palette next. Open Repository and About now travel as regular menu commands, and the folder/terminal/copy/remote items run through the renderer's existing IPC. - getRefs: every branch and tag (annotated tags peeled) in one for-each-ref. - searchFiles: tracked paths cached per repo in main, keyed to the git index's mtime+size, fuzzy-ranked in yielding chunks so only the best matches cross IPC; a newer query from the same window abandons the older scan. --- src/main/git/read/index.ts | 2 + src/main/git/read/refs.test.ts | 78 +++++++++ src/main/git/read/refs.ts | 50 ++++++ src/main/git/read/tracked-files.ts | 29 ++++ src/main/ipc/index.ts | 2 + src/main/ipc/search.ts | 16 ++ src/main/menu.ts | 183 +++++--------------- src/main/search/file-index.test.ts | 96 ++++++++++ src/main/search/file-index.ts | 117 +++++++++++++ src/preload/index.ts | 18 +- src/renderer/src/App.tsx | 36 ++-- src/renderer/src/lib/useAppCommands.test.ts | 30 ++++ src/renderer/src/lib/useAppCommands.ts | 141 +++++++++++++++ src/renderer/src/lib/useOsIntegration.ts | 70 +------- src/shared/commands.test.ts | 43 +++++ src/shared/commands.ts | 124 +++++++++++++ src/shared/fuzzy.ts | 12 +- src/shared/ipc.ts | 41 ++--- src/shared/types.ts | 27 +++ 19 files changed, 864 insertions(+), 251 deletions(-) create mode 100644 src/main/git/read/refs.test.ts create mode 100644 src/main/git/read/refs.ts create mode 100644 src/main/git/read/tracked-files.ts create mode 100644 src/main/ipc/search.ts create mode 100644 src/main/search/file-index.test.ts create mode 100644 src/main/search/file-index.ts create mode 100644 src/renderer/src/lib/useAppCommands.test.ts create mode 100644 src/renderer/src/lib/useAppCommands.ts create mode 100644 src/shared/commands.test.ts create mode 100644 src/shared/commands.ts diff --git a/src/main/git/read/index.ts b/src/main/git/read/index.ts index 493ba8f..3829f0e 100644 --- a/src/main/git/read/index.ts +++ b/src/main/git/read/index.ts @@ -28,5 +28,7 @@ export { parseMergeTreeNames } from './merge' export { getPatchIds } from './patch-ids' +export { getRefs, parseRefs } from './refs' export { getRemoteCloneUrl, getRemoteWebUrl, toWebUrl } from './remotes' export { getSquashLandings } from './squash-landings' +export { getIndexPath, listTrackedFiles } from './tracked-files' diff --git a/src/main/git/read/refs.test.ts b/src/main/git/read/refs.test.ts new file mode 100644 index 0000000..fefc535 --- /dev/null +++ b/src/main/git/read/refs.test.ts @@ -0,0 +1,78 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getRefs, parseRefs } from './refs' + +describe('parseRefs', () => { + it('shortens names, peels annotated tags and skips symbolic refs', () => { + const out = [ + 'refs/heads/main\x00aaa\x00\x00100\x00', + 'refs/remotes/origin/HEAD\x00aaa\x00\x00100\x00refs/remotes/origin/main', + 'refs/remotes/origin/main\x00aaa\x00\x00100\x00', + 'refs/tags/v1\x00tagobj\x00ccc\x00200\x00', + 'refs/tags/light\x00ddd\x00\x00300\x00', + '' + ].join('\n') + expect(parseRefs(out)).toEqual([ + { kind: 'local', name: 'main', hash: 'aaa', date: 100 }, + { kind: 'remote', name: 'origin/main', hash: 'aaa', date: 100 }, + { kind: 'tag', name: 'v1', hash: 'ccc', date: 200 }, + { kind: 'tag', name: 'light', hash: 'ddd', date: 300 } + ]) + }) +}) + +// Integration: the real `git` binary against a throwaway, hermetic repo. +let repo: string +let configHome: string + +function git(args: string[]): string { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Test Author', + GIT_AUTHOR_EMAIL: 'author@example.com', + GIT_COMMITTER_NAME: 'Test Author', + GIT_COMMITTER_EMAIL: 'author@example.com' + } + }).trim() +} + +beforeAll(() => { + configHome = mkdtempSync(join(tmpdir(), 'gitgrove-config-')) + const emptyConfig = join(configHome, 'gitconfig') + writeFileSync(emptyConfig, '') + process.env.GIT_CONFIG_GLOBAL = emptyConfig + process.env.GIT_CONFIG_SYSTEM = emptyConfig + + repo = mkdtempSync(join(tmpdir(), 'gitgrove-refs-')) + git(['init', '-q', '-b', 'main']) + git(['config', 'commit.gpgsign', 'false']) + git(['config', 'tag.gpgsign', 'false']) + writeFileSync(join(repo, 'a.txt'), 'a\n') + git(['add', '.']) + git(['commit', '-q', '-m', 'first']) + git(['branch', 'feature/x']) + git(['tag', '-a', 'v1.0', '-m', 'release']) +}) + +afterAll(() => { + rmSync(repo, { recursive: true, force: true }) + rmSync(configHome, { recursive: true, force: true }) +}) + +describe('getRefs', () => { + it('lists branches and tags, annotated tags peeled to their commit', async () => { + const head = git(['rev-parse', 'HEAD']) + const refs = await getRefs(repo) + const byName = new Map(refs.map((r) => [r.name, r])) + expect(byName.get('main')).toMatchObject({ kind: 'local', hash: head }) + expect(byName.get('feature/x')).toMatchObject({ kind: 'local', hash: head }) + expect(byName.get('v1.0')).toMatchObject({ kind: 'tag', hash: head }) + expect(byName.get('v1.0')!.date).toBeGreaterThan(0) + }) +}) diff --git a/src/main/git/read/refs.ts b/src/main/git/read/refs.ts new file mode 100644 index 0000000..99187b8 --- /dev/null +++ b/src/main/git/read/refs.ts @@ -0,0 +1,50 @@ +// Every branch and tag in one read — what the command palette searches. One +// `for-each-ref` over refs/heads, refs/remotes and refs/tags; cheap even with +// tens of thousands of refs (it reads packed-refs, no history walk). + +import type { RefEntry } from '@shared/types' +import { runGit } from './core' + +const PREFIXES: [string, RefEntry['kind']][] = [ + ['refs/heads/', 'local'], + ['refs/remotes/', 'remote'], + ['refs/tags/', 'tag'] +] + +/** + * Parse `for-each-ref` records of `refname NUL objectname NUL *objectname NUL + * creatordate:unix NUL symref`, one per line (refnames can't hold NUL or a + * newline, so this is exact). Symbolic refs (`origin/HEAD`) are pointers, not + * branches, and drop out. Pure + exported for tests. + */ +export function parseRefs(out: string): RefEntry[] { + const refs: RefEntry[] = [] + for (const line of out.split('\n')) { + if (!line) continue + const [refname, object, peeled, date, symref] = line.split('\0') + if (symref) continue + const prefix = PREFIXES.find(([p]) => refname.startsWith(p)) + if (!prefix) continue + refs.push({ + kind: prefix[1], + name: refname.slice(prefix[0].length), + // An annotated tag names a tag object; `*objectname` is its commit. + hash: peeled || object, + date: Number(date) || 0 + }) + } + return refs +} + +/** All local branches, remote branches and tags, most recently touched first. */ +export async function getRefs(repoPath: string): Promise { + const out = await runGit(repoPath, [ + 'for-each-ref', + '--sort=-creatordate', + '--format=%(refname)%00%(objectname)%00%(*objectname)%00%(creatordate:unix)%00%(symref)', + 'refs/heads', + 'refs/remotes', + 'refs/tags' + ]) + return parseRefs(out) +} diff --git a/src/main/git/read/tracked-files.ts b/src/main/git/read/tracked-files.ts new file mode 100644 index 0000000..656250f --- /dev/null +++ b/src/main/git/read/tracked-files.ts @@ -0,0 +1,29 @@ +// The repository's tracked files and where its index lives — the raw material +// of the command palette's file search (main/search/file-index.ts). + +import { isAbsolute, join } from 'node:path' +import { runGit } from './core' + +/** + * Every tracked path, repo-relative and `/`-separated. `ls-files` reads the + * index only — no working-tree walk — so it stays fast on huge repos. NUL- + * delimited (`-z`) so any filename parses exactly. A conflicted path has one + * index entry per stage; the sorted output lets consecutive repeats collapse. + */ +export async function listTrackedFiles(repoPath: string): Promise { + const out = await runGit(repoPath, ['ls-files', '-z']) + const paths: string[] = [] + for (const path of out.split('\0')) { + if (path && path !== paths[paths.length - 1]) paths.push(path) + } + return paths +} + +/** + * Absolute path of the repo's index file. Asked of git rather than assumed to + * be `.git/index`: in a linked worktree `.git` is a file pointing elsewhere. + */ +export async function getIndexPath(repoPath: string): Promise { + const path = (await runGit(repoPath, ['rev-parse', '--git-path', 'index'])).trim() + return isAbsolute(path) ? path : join(repoPath, path) +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 460a90a..dc9122c 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -19,6 +19,7 @@ import { registerHistoryHandlers } from './history' import { registerIntegrationHandlers } from './integrate' import { registerMaintenanceHandlers } from './maintenance' import { registerRepoHandlers } from './repo' +import { registerSearchHandlers } from './search' import { registerStagingHandlers } from './staging' import { registerStashHandlers } from './stash' import { registerSyncHandlers } from './sync' @@ -39,6 +40,7 @@ export function registerIpc(ctx: IpcContext): void { registerRepoHandlers(deps) registerHistoryHandlers() + registerSearchHandlers() registerStagingHandlers(deps) registerSyncHandlers(deps) registerAccountHandlers(deps) diff --git a/src/main/ipc/search.ts b/src/main/ipc/search.ts new file mode 100644 index 0000000..6946087 --- /dev/null +++ b/src/main/ipc/search.ts @@ -0,0 +1,16 @@ +// Command-palette reads: every branch/tag, and the fuzzy file search over the +// repo's tracked files (ranked in main — see search/file-index.ts). + +import { IPC } from '@shared/ipc' +import { ipcMain } from 'electron' +import { getRefs } from '../git/read' +import { searchFiles } from '../search/file-index' + +export function registerSearchHandlers(): void { + ipcMain.handle(IPC.refs, (_e, repoPath: string) => getRefs(repoPath)) + // Keyed by the calling window, so each window's newer query supersedes only + // its own older one. + ipcMain.handle(IPC.searchFiles, (e, repoPath: string, query: string, limit: number) => + searchFiles(repoPath, query, limit, String(e.sender.id)) + ) +} diff --git a/src/main/menu.ts b/src/main/menu.ts index 88fcb01..9ba9e57 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -2,21 +2,19 @@ // actions are disabled until one is open). On Windows/Linux the renderer's // custom menu bar pops these same native submenus, so every action and role // here works without being reimplemented in the UI. +// +// App actions come from the shared command registry (shared/commands.ts) — the +// same list the command palette searches — so the two surfaces can never +// disagree on a label or a shortcut. Clicking one just sends its id to the +// focused window's renderer, which runs it. import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { delimiter, join } from 'node:path' -import { IPC, type MenuCommand } from '@shared/ipc' -import { - app, - type BrowserWindow, - clipboard, - Menu, - type MenuItemConstructorOptions, - shell -} from 'electron' +import { type AppCommandId, appCommand, commandTitle } from '@shared/commands' +import { IPC } from '@shared/ipc' +import { app, type BrowserWindow, Menu, type MenuItemConstructorOptions, shell } from 'electron' import { REPO_URL } from './app-info' -import { getRemoteWebUrl } from './git/read' /** * What the menu needs from the app. GitGrove is multi-window: `getWindow` is @@ -36,9 +34,23 @@ export interface MenuContext { export function buildMenu(ctx: MenuContext): void { const { getWindow, getRepoPath } = ctx const repoPath = getRepoPath() - const send = (channel: string, ...args: unknown[]) => - getWindow()?.webContents.send(channel, ...args) - const sendCommand = (command: MenuCommand) => send(IPC.menuCommand, command) + const sendCommand = (id: AppCommandId) => getWindow()?.webContents.send(IPC.menuCommand, id) + + /** A registry command as a menu item: its title, shortcut and repo gate. */ + const commandItem = (id: AppCommandId): MenuItemConstructorOptions => { + const command = appCommand(id) + return { + label: commandTitle(id, process.platform), + accelerator: command.accelerator, + enabled: !command.needsRepo || !!repoPath, + click: () => sendCommand(id) + } + } + const checkForUpdatesItem: MenuItemConstructorOptions = { + label: commandTitle('check-updates', process.platform), + // Runs in main: it must work even with no window to send a command to. + click: () => ctx.checkForUpdates() + } const isMac = process.platform === 'darwin' const template: MenuItemConstructorOptions[] = [ @@ -47,20 +59,10 @@ export function buildMenu(ctx: MenuContext): void { { label: app.name, submenu: [ - { - label: `About ${app.name}`, - click: () => send(IPC.menuShowAbout) - }, - { - label: 'Check for Updates…', - click: () => ctx.checkForUpdates() - }, + commandItem('about'), + checkForUpdatesItem, { type: 'separator' as const }, - { - label: 'Settings…', - accelerator: 'CmdOrCtrl+,', - click: () => sendCommand('settings') - }, + commandItem('settings'), { type: 'separator' as const }, { role: 'hide' as const }, { role: 'hideOthers' as const }, @@ -83,28 +85,11 @@ export function buildMenu(ctx: MenuContext): void { click: () => ctx.newWindow() }, { type: 'separator' }, - { - label: 'Open Repository…', - accelerator: 'CmdOrCtrl+O', - click: () => send(IPC.menuOpenRepo) - }, - { - label: 'Clone Repository…', - accelerator: 'CmdOrCtrl+Shift+O', - click: () => sendCommand('clone') - }, + commandItem('open-repo'), + commandItem('clone'), { type: 'separator' }, // macOS hosts this in the app menu (the conventional settings slot). - ...(isMac - ? [] - : [ - { - label: 'Settings…', - accelerator: 'CmdOrCtrl+,', - click: () => sendCommand('settings') - } as MenuItemConstructorOptions, - { type: 'separator' as const } - ]), + ...(isMac ? [] : [commandItem('settings'), { type: 'separator' as const }]), isMac ? { role: 'close' } : { role: 'quit' } ] }, @@ -113,97 +98,33 @@ export function buildMenu(ctx: MenuContext): void { // open in the renderer; disabled until one is. label: 'Repository', submenu: [ - { - label: 'Fetch', - accelerator: 'CmdOrCtrl+Shift+F', - enabled: !!repoPath, - click: () => sendCommand('fetch') - }, - { - label: 'Pull', - accelerator: 'CmdOrCtrl+Shift+P', - enabled: !!repoPath, - click: () => sendCommand('pull') - }, - { - label: 'Push', - accelerator: 'CmdOrCtrl+P', - enabled: !!repoPath, - click: () => sendCommand('push') - }, + commandItem('fetch'), + commandItem('pull'), + commandItem('push'), { type: 'separator' }, - { - label: 'New Branch…', - accelerator: 'CmdOrCtrl+Shift+N', - enabled: !!repoPath, - click: () => sendCommand('new-branch') - }, - { - label: 'Stash All Changes…', - enabled: !!repoPath, - click: () => sendCommand('stash') - }, + commandItem('new-branch'), + commandItem('stash'), { type: 'separator' }, - // No accelerator: a global Cmd/Ctrl+Z here would hijack text undo in - // the commit composer. The Changes banner is the primary affordance. - { - label: 'Undo Last Action', - enabled: !!repoPath, - click: () => sendCommand('undo') - }, + commandItem('undo'), { type: 'separator' }, - { - label: 'Speed Up Large Repository', - enabled: !!repoPath, - click: () => sendCommand('optimize') - }, + commandItem('optimize'), { type: 'separator' }, - { - label: 'Worktrees…', - enabled: !!repoPath, - click: () => sendCommand('worktrees') - }, - { - label: 'Submodules…', - enabled: !!repoPath, - click: () => sendCommand('submodules') - }, + commandItem('worktrees'), + commandItem('submodules'), { type: 'separator' }, - { - label: isMac - ? 'Reveal in Finder' - : process.platform === 'win32' - ? 'Show in Explorer' - : 'Open Folder', - enabled: !!repoPath, - click: () => repoPath && shell.openPath(repoPath) - }, - { - label: 'Open in Terminal', - enabled: !!repoPath, - click: () => repoPath && openTerminal(repoPath) - }, + commandItem('reveal-repo'), + commandItem('open-terminal'), { type: 'separator' }, - { - label: 'Copy Repository Path', - enabled: !!repoPath, - click: () => repoPath && clipboard.writeText(repoPath) - }, - { - label: 'View on Remote', - enabled: !!repoPath, - click: async () => { - if (!repoPath) return - const url = await getRemoteWebUrl(repoPath) - if (url) shell.openExternal(url) - } - } + commandItem('copy-repo-path'), + commandItem('view-on-remote') ] }, { role: 'editMenu' }, { label: 'View', submenu: [ + commandItem('search-everything'), + { type: 'separator' }, { role: 'reload' }, { role: 'forceReload' }, { role: 'toggleDevTools' }, @@ -229,17 +150,7 @@ export function buildMenu(ctx: MenuContext): void { }, ...(isMac ? [] - : [ - { type: 'separator' as const }, - { - label: 'Check for Updates…', - click: () => ctx.checkForUpdates() - }, - { - label: `About ${app.name}`, - click: () => send(IPC.menuShowAbout) - } - ]) + : [{ type: 'separator' as const }, checkForUpdatesItem, commandItem('about')]) ] } ] diff --git a/src/main/search/file-index.test.ts b/src/main/search/file-index.test.ts new file mode 100644 index 0000000..86f3e20 --- /dev/null +++ b/src/main/search/file-index.test.ts @@ -0,0 +1,96 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { searchFiles } from './file-index' + +// Integration tests: the real `git` binary against a throwaway repo, with +// global/system config pointed at an empty file so the developer's own config +// can't leak in. + +let repo: string +let configHome: string + +function git(args: string[]): string { + return execFileSync('git', args, { cwd: repo, encoding: 'utf8' }).trim() +} + +function addFile(path: string) { + const parts = path.split('/') + if (parts.length > 1) mkdirSync(join(repo, ...parts.slice(0, -1)), { recursive: true }) + writeFileSync(join(repo, ...parts), `${path}\n`) +} + +beforeAll(() => { + configHome = mkdtempSync(join(tmpdir(), 'gitgrove-config-')) + const emptyConfig = join(configHome, 'gitconfig') + writeFileSync(emptyConfig, '') + process.env.GIT_CONFIG_GLOBAL = emptyConfig + process.env.GIT_CONFIG_SYSTEM = emptyConfig + + repo = mkdtempSync(join(tmpdir(), 'gitgrove-files-')) + git(['init', '-q', '-b', 'main']) + for (const path of [ + 'README.md', + 'src/components/GraphView.tsx', + 'src/graphics/old/view.ts', + 'docs/ümläut guide.md' + ]) { + addFile(path) + } + git(['add', '.']) +}) + +afterAll(() => { + rmSync(repo, { recursive: true, force: true }) + rmSync(configHome, { recursive: true, force: true }) +}) + +describe('searchFiles', () => { + it('ranks the file-name match first and reports positions', async () => { + const result = await searchFiles(repo, 'graphview', 10, 'ranking') + expect(result?.matches[0].path).toBe('src/components/GraphView.tsx') + const { path, positions } = result!.matches[0] + expect(positions.map((i) => path[i]).join('')).toBe('GraphView') + }) + + it('counts every match while returning at most `limit`', async () => { + const result = await searchFiles(repo, 'e', 1, 'limit') + expect(result?.matches).toHaveLength(1) + expect(result!.total).toBeGreaterThan(1) + }) + + it('matches non-ASCII paths exactly', async () => { + const result = await searchFiles(repo, 'ümläut', 10, 'unicode') + expect(result?.matches.map((m) => m.path)).toEqual(['docs/ümläut guide.md']) + }) + + it('returns nothing for an empty query', async () => { + expect(await searchFiles(repo, ' ', 10, 'empty')).toEqual({ matches: [], total: 0 }) + }) + + it('picks up newly tracked files once the index changes', async () => { + expect((await searchFiles(repo, 'freshfile', 10, 'fresh'))?.matches).toHaveLength(0) + addFile('lib/fresh-file.ts') + git(['add', 'lib/fresh-file.ts']) + const result = await searchFiles(repo, 'freshfile', 10, 'fresh') + expect(result?.matches.map((m) => m.path)).toEqual(['lib/fresh-file.ts']) + }) + + it('abandons a query superseded by the same caller', async () => { + const older = searchFiles(repo, 'graph', 10, 'typist') + const newer = searchFiles(repo, 'graphv', 10, 'typist') + expect(await older).toBeNull() + expect((await newer)?.matches[0].path).toBe('src/components/GraphView.tsx') + }) + + it('never lets one caller cancel another', async () => { + const [a, b] = await Promise.all([ + searchFiles(repo, 'readme', 10, 'window-a'), + searchFiles(repo, 'readme', 10, 'window-b') + ]) + expect(a?.matches[0].path).toBe('README.md') + expect(b?.matches[0].path).toBe('README.md') + }) +}) diff --git a/src/main/search/file-index.ts b/src/main/search/file-index.ts new file mode 100644 index 0000000..787ce8f --- /dev/null +++ b/src/main/search/file-index.ts @@ -0,0 +1,117 @@ +// The command palette's file search: a per-repo cache of every tracked path +// (plus its case-folded twin, folded once rather than per keystroke), fuzzy- +// ranked here in main so only the few best matches ever cross IPC — shipping +// a 500k-path list to the renderer on each keystroke would stall it. +// +// Freshness: the cache is keyed to the index file's mtime + size. Anything +// that changes the tracked set (add, rm, checkout, pull, a commit) rewrites +// the index, so one cheap stat per query is enough to know when to re-list. +// +// Responsiveness: main serves every window's IPC, so a long scan must never +// hog it. The scan yields to the event loop every CHUNK paths, and a newer +// query from the same caller abandons the older one at its next yield — fast +// typing never piles up scans. + +import { stat } from 'node:fs/promises' +import { foldCase, fuzzyMatchPath, fuzzyScorePath, normalizeQuery, TopMatches } from '@shared/fuzzy' +import type { FileSearchResult } from '@shared/types' +import { getIndexPath, listTrackedFiles } from '../git/read' + +/** Paths scored between yields: a few ms of work, never a visible stall. */ +const CHUNK = 20_000 +/** Repos kept indexed at once (multi-window); the least recently used goes. */ +const MAX_CACHED_REPOS = 3 + +interface FileIndex { + paths: string[] + folded: string[] + /** Index-file mtime + size when `paths` was listed. */ + stamp: string +} + +const indexes = new Map() +const loading = new Map>() +const indexPaths = new Map() +/** The newest query per caller; older scans for the same caller bail out. */ +const latestQuery = new Map() +let querySeq = 0 + +async function indexStamp(repoPath: string): Promise { + let indexPath = indexPaths.get(repoPath) + if (!indexPath) { + indexPath = await getIndexPath(repoPath) + indexPaths.set(repoPath, indexPath) + } + // No index yet (a fresh repo with nothing staged) is a state too. + const info = await stat(indexPath).catch(() => null) + return info ? `${info.mtimeMs}:${info.size}` : 'none' +} + +/** The repo's file index, re-listed only when the git index has changed. */ +async function fileIndex(repoPath: string): Promise { + const stamp = await indexStamp(repoPath) + const cached = indexes.get(repoPath) + if (cached?.stamp === stamp) { + // Re-insert to mark it most recently used. + indexes.delete(repoPath) + indexes.set(repoPath, cached) + return cached + } + // Concurrent queries during a (possibly seconds-long) first listing share it. + const pending = loading.get(repoPath) + if (pending) return pending + const load = listTrackedFiles(repoPath) + .then((paths) => { + const index = { paths, folded: paths.map(foldCase), stamp } + indexes.delete(repoPath) + indexes.set(repoPath, index) + while (indexes.size > MAX_CACHED_REPOS) indexes.delete(indexes.keys().next().value!) + return index + }) + .finally(() => loading.delete(repoPath)) + loading.set(repoPath, load) + return load +} + +const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve)) + +/** + * The `limit` tracked files best matching `query`, best first. `caller` + * identifies who's typing (one window's palette): its newer query supersedes + * this one, which then resolves null. An empty query matches nothing — the + * palette shows other things until the user types. + */ +export async function searchFiles( + repoPath: string, + query: string, + limit: number, + caller: string +): Promise { + const seq = ++querySeq + latestQuery.set(caller, seq) + const needle = normalizeQuery(query) + const index = await fileIndex(repoPath) + // Typed past while the index loaded: don't even start scanning. + if (latestQuery.get(caller) !== seq) return null + if (needle.length === 0) return { matches: [], total: 0 } + + const top = new TopMatches(limit) + const { paths, folded } = index + for (let start = 0; start < paths.length; start += CHUNK) { + if (start > 0) { + await yieldToEventLoop() + if (latestQuery.get(caller) !== seq) return null + } + const end = Math.min(start + CHUNK, paths.length) + for (let i = start; i < end; i++) { + const score = fuzzyScorePath(needle, paths[i], folded[i]) + if (score !== null) top.add(i, score, paths[i].length) + } + } + // Positions only for the winners — the scan itself never allocates. + const matches = top.items().map((i) => { + const match = fuzzyMatchPath(needle, paths[i], folded[i])! + return { path: paths[i], score: match.score, positions: match.positions } + }) + return { matches, total: top.total } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index cdbf49e..f8aec4f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,5 @@ -import { type GitGroveApi, IPC, type MenuCommand } from '@shared/ipc' +import type { AppCommandId } from '@shared/commands' +import { type GitGroveApi, IPC } from '@shared/ipc' import type { AiChunk, ChangedFile, @@ -40,6 +41,9 @@ const api: GitGroveApi = { graphSquashLandings: (repoPath, mainline, candidates) => ipcRenderer.invoke(IPC.graphSquashLandings, repoPath, mainline, candidates), commitIndex: (repoPath, hash) => ipcRenderer.invoke(IPC.commitIndex, repoPath, hash), + refs: (repoPath) => ipcRenderer.invoke(IPC.refs, repoPath), + searchFiles: (repoPath, query, limit) => + ipcRenderer.invoke(IPC.searchFiles, repoPath, query, limit), fileHistory: (repoPath, path, ref) => ipcRenderer.invoke(IPC.fileHistory, repoPath, path, ref), blame: (repoPath, path, ref) => ipcRenderer.invoke(IPC.blame, repoPath, path, ref), commitFiles: (repoPath, hash) => ipcRenderer.invoke(IPC.commitFiles, repoPath, hash), @@ -163,18 +167,8 @@ const api: GitGroveApi = { ipcRenderer.on(IPC.openRepoRequest, listener) return () => ipcRenderer.removeListener(IPC.openRepoRequest, listener) }, - onMenuOpenRepo: (handler) => { - const listener = () => handler() - ipcRenderer.on(IPC.menuOpenRepo, listener) - return () => ipcRenderer.removeListener(IPC.menuOpenRepo, listener) - }, - onShowAbout: (handler) => { - const listener = () => handler() - ipcRenderer.on(IPC.menuShowAbout, listener) - return () => ipcRenderer.removeListener(IPC.menuShowAbout, listener) - }, onMenuCommand: (handler) => { - const listener = (_e: unknown, command: MenuCommand) => handler(command) + const listener = (_e: unknown, command: AppCommandId) => handler(command) ipcRenderer.on(IPC.menuCommand, listener) return () => ipcRenderer.removeListener(IPC.menuCommand, listener) }, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6ab3ba1..be7b585 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -61,6 +61,7 @@ import { usePersistentState } from './lib/persist' import type { BranchPrs } from './lib/pr-order' import { createRepoGeneration } from './lib/repoGeneration' import { useTheme } from './lib/theme' +import { type AppTab, useAppCommands } from './lib/useAppCommands' import { useCredentialPrompts } from './lib/useCredentialPrompts' import { useDiffLoader } from './lib/useDiffLoader' import { useGitAvailability } from './lib/useGitAvailability' @@ -71,7 +72,7 @@ import { usePullRequests } from './lib/usePullRequests' import { type MissingRepoInfo, useRepoRecovery } from './lib/useRepoRecovery' import { useUpdateBanner } from './lib/useUpdateBanner' -type Tab = 'changes' | 'history' | 'graph' +type Tab = AppTab /** Stable stand-in while sync status hasn't loaded: a fresh `[]` per render * would re-run the Graph layout every render, and the layout's report-up @@ -1227,23 +1228,32 @@ export function App() { [loadLog, fail, markLogStale] ) - // ── OS integration: menu commands + filesystem change notifications ──────── - // Native menu commands, the watcher refresh, the focus refresh and the quiet - // background fetch — see useOsIntegration. - useOsIntegration({ + // ── Named commands + OS integration ─────────────────────────────────────── + // The one dispatcher behind the native menu and the command palette — see + // useAppCommands. + const [searchOpen, setSearchOpen] = useState(false) + const { runCommand, availableCommands } = useAppCommands({ repo, - repoRef, - busyRef, - syncRef, - refreshRef, - doUndoRef, - runOpRef, + sync, + undo, + opInFlight: !!repoState?.op, pickRepo, doSync, + doUndo, + runOp, reloadBranches, - openModal: setModal + openModal: setModal, + switchTab, + setThemePref, + openAbout: () => setAboutOpen(true), + toggleSearch: () => setSearchOpen((open) => !open), + fail }) + // The watcher refresh, the focus refresh, the quiet background fetch and the + // menu subscription — see useOsIntegration. + useOsIntegration({ repo, repoRef, busyRef, syncRef, refreshRef, runCommand }) + // ── About dialog + auto-update ───────────────────────────────────────────── useEffect(() => { window.gitgrove @@ -1252,8 +1262,6 @@ export function App() { .catch(() => {}) }, []) - useEffect(() => window.gitgrove.onShowAbout(() => setAboutOpen(true)), []) - // Window title = the open repo, so multiple GitGrove windows stay tellable // apart in the Window menu, Alt-Tab/Mission Control and the taskbar. (The // in-window title bar is custom, so this never paints inside the app.) diff --git a/src/renderer/src/lib/useAppCommands.test.ts b/src/renderer/src/lib/useAppCommands.test.ts new file mode 100644 index 0000000..f3d31d2 --- /dev/null +++ b/src/renderer/src/lib/useAppCommands.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'bun:test' +import { appCommand } from '@shared/commands' +import { type CommandContext, isCommandAvailable, paletteCommands } from './useAppCommands' + +const open: CommandContext = { hasRepo: true, hasRemotes: true, canUndo: true } + +describe('isCommandAvailable', () => { + it('hides repo commands until a repo is open', () => { + const closed = { hasRepo: false, hasRemotes: false, canUndo: false } + expect(isCommandAvailable(appCommand('fetch'), closed)).toBe(false) + expect(isCommandAvailable(appCommand('clone'), closed)).toBe(true) + }) + + it('offers undo only when there is something to undo', () => { + expect(isCommandAvailable(appCommand('undo'), open)).toBe(true) + expect(isCommandAvailable(appCommand('undo'), { ...open, canUndo: false })).toBe(false) + }) + + it('offers View on Remote only with a remote', () => { + expect(isCommandAvailable(appCommand('view-on-remote'), { ...open, hasRemotes: false })).toBe( + false + ) + }) +}) + +describe('paletteCommands', () => { + it('never lists the palette itself', () => { + expect(paletteCommands(open).some((c) => c.id === 'search-everything')).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/useAppCommands.ts b/src/renderer/src/lib/useAppCommands.ts new file mode 100644 index 0000000..a13aa64 --- /dev/null +++ b/src/renderer/src/lib/useAppCommands.ts @@ -0,0 +1,141 @@ +// Runs the app's named commands (shared/commands.ts) — the one dispatcher +// behind both the native application menu and the command palette, so a +// command behaves identically whichever way the user reaches it. +// +// `runCommand` has a stable identity and always reads the latest state (via +// useEvent), so the menu subscription in useOsIntegration installs once. + +import { APP_COMMANDS, type AppCommand, type AppCommandId } from '@shared/commands' +import type { RepoSummary, SyncStatus, UndoSnapshot } from '@shared/types' +import type { Modal } from '../components/app/AppModals' +import type { SyncAction } from '../components/toolbar/SyncButton' +import type { ThemePref } from './theme' +import { useEvent } from './useEvent' + +export type AppTab = 'changes' | 'history' | 'graph' + +interface Params { + repo: RepoSummary | null + sync: SyncStatus | null + /** The one-step undo, when there is something to undo. */ + undo: UndoSnapshot | null + /** True while a merge/rebase/… owns the working tree (no undo then). */ + opInFlight: boolean + pickRepo: () => void + doSync: (action: SyncAction) => void + doUndo: () => void + runOp: (fn: () => Promise) => Promise + reloadBranches: () => void + openModal: (modal: Modal) => void + switchTab: (tab: AppTab) => void + setThemePref: (pref: ThemePref) => void + openAbout: () => void + toggleSearch: () => void + fail: (e: unknown) => void +} + +/** What decides whether a command makes sense right now. */ +export interface CommandContext { + hasRepo: boolean + hasRemotes: boolean + /** An undo is recorded and no merge/rebase/… owns the working tree. */ + canUndo: boolean +} + +export function isCommandAvailable(command: AppCommand, ctx: CommandContext): boolean { + if (command.needsRepo && !ctx.hasRepo) return false + if (command.id === 'undo') return ctx.canUndo + if (command.id === 'view-on-remote') return ctx.hasRemotes + return true +} + +/** The commands the palette offers, in registry order — never itself. */ +export function paletteCommands(ctx: CommandContext): AppCommand[] { + return APP_COMMANDS.filter((c) => c.id !== 'search-everything' && isCommandAvailable(c, ctx)) +} + +export interface AppCommands { + runCommand: (id: AppCommandId) => void + /** paletteCommands for the current state. */ + availableCommands: () => AppCommand[] +} + +export function useAppCommands(params: Params): AppCommands { + const runCommand = useEvent((id: AppCommandId) => { + const command = APP_COMMANDS.find((c) => c.id === id) + // A menu click can race a repo close; the item was enabled a moment ago. + if (!command || (command.needsRepo && !params.repo)) return + const repoPath = params.repo?.path ?? '' + const gg = window.gitgrove + switch (id) { + case 'search-everything': + return params.toggleSearch() + case 'open-repo': + return params.pickRepo() + case 'clone': + return params.openModal({ kind: 'clone' }) + case 'settings': + return params.openModal({ kind: 'settings' }) + case 'fetch': + case 'pull': + case 'push': + return params.doSync(id) + case 'new-branch': + // Fresh enumeration for the dialog's default-branch option. + params.reloadBranches() + return params.openModal({ kind: 'new-branch' }) + case 'stash': + return params.openModal({ kind: 'stash' }) + case 'undo': + return params.doUndo() + case 'optimize': + params.runOp(() => gg.optimizeRepo(repoPath)) + return + case 'worktrees': + return params.openModal({ kind: 'worktrees' }) + case 'submodules': + return params.openModal({ kind: 'submodules' }) + case 'reveal-repo': + gg.revealRepo(repoPath).catch(params.fail) + return + case 'open-terminal': + gg.openTerminal(repoPath).catch(params.fail) + return + case 'copy-repo-path': + gg.clipboardWrite(repoPath).catch(params.fail) + return + case 'view-on-remote': + gg.remoteUrl(repoPath) + .then((url) => (url ? gg.openExternal(url) : undefined)) + .catch(params.fail) + return + case 'show-changes': + return params.switchTab('changes') + case 'show-history': + return params.switchTab('history') + case 'show-graph': + return params.switchTab('graph') + case 'theme-light': + return params.setThemePref('light') + case 'theme-dark': + return params.setThemePref('dark') + case 'theme-system': + return params.setThemePref('system') + case 'check-updates': + gg.checkForUpdates(true).catch(params.fail) + return + case 'about': + return params.openAbout() + } + }) + + const availableCommands = useEvent(() => + paletteCommands({ + hasRepo: !!params.repo, + hasRemotes: (params.sync?.remotes.length ?? 0) > 0, + canUndo: !!params.undo && !params.opInFlight + }) + ) + + return { runCommand, availableCommands } +} diff --git a/src/renderer/src/lib/useOsIntegration.ts b/src/renderer/src/lib/useOsIntegration.ts index 93aa772..12f1721 100644 --- a/src/renderer/src/lib/useOsIntegration.ts +++ b/src/renderer/src/lib/useOsIntegration.ts @@ -2,15 +2,13 @@ // the watcher-driven refresh, the focus refresh, and the quiet background fetch. // // Every effect here reads the app's live refs (repo/busy/sync) and the stable -// action refs (refresh/undo/runOp) rather than depending on them, so the +// action refs (refresh) rather than depending on them, so the // subscriptions install once and never tear down — the same live-ref pattern // the codebase uses for diffRef. Hence the biome-ignore directives below. -import type { MenuCommand } from '@shared/ipc' +import type { AppCommandId } from '@shared/commands' import type { RepoSummary, SyncStatus } from '@shared/types' import { type RefObject, useEffect } from 'react' -import type { Modal } from '../components/app/AppModals' -import type { SyncAction } from '../components/toolbar/SyncButton' /** Background fetch cadence (ms) — quiet, skipped while an op runs. */ const AUTO_FETCH_INTERVAL = 10 * 60 * 1000 @@ -21,12 +19,8 @@ interface Params { busyRef: RefObject syncRef: RefObject refreshRef: RefObject<() => Promise> - doUndoRef: RefObject<() => Promise> - runOpRef: RefObject<(fn: () => Promise) => Promise> - pickRepo: () => void - doSync: (action: SyncAction) => void - reloadBranches: () => void - openModal: (modal: Modal) => void + /** The shared command dispatcher (useAppCommands) — stable identity. */ + runCommand: (id: AppCommandId) => void } export function useOsIntegration({ @@ -35,61 +29,9 @@ export function useOsIntegration({ busyRef, syncRef, refreshRef, - doUndoRef, - runOpRef, - pickRepo, - doSync, - reloadBranches, - openModal + runCommand }: Params): void { - useEffect(() => window.gitgrove.onMenuOpenRepo(() => pickRepo()), [pickRepo]) - - // biome-ignore lint/correctness/useExhaustiveDependencies: repoRef/doUndoRef/runOpRef are refs read for their live values; the handlers it dispatches to are listed. - useEffect( - () => - window.gitgrove.onMenuCommand((command: MenuCommand) => { - const hasRepo = !!repoRef.current - switch (command) { - case 'settings': - openModal({ kind: 'settings' }) - break - case 'clone': - openModal({ kind: 'clone' }) - break - case 'fetch': - case 'pull': - case 'push': - if (hasRepo) doSync(command) - break - case 'new-branch': - if (hasRepo) { - // Fresh enumeration for the dialog's default-branch option. - reloadBranches() - openModal({ kind: 'new-branch' }) - } - break - case 'undo': - if (hasRepo) doUndoRef.current() - break - case 'stash': - if (hasRepo) openModal({ kind: 'stash' }) - break - case 'worktrees': - if (hasRepo) openModal({ kind: 'worktrees' }) - break - case 'submodules': - if (hasRepo) openModal({ kind: 'submodules' }) - break - case 'optimize': - if (hasRepo) { - const repoPath = repoRef.current?.path - if (repoPath) runOpRef.current(() => window.gitgrove.optimizeRepo(repoPath)) - } - break - } - }), - [doSync, reloadBranches, openModal] - ) + useEffect(() => window.gitgrove.onMenuCommand(runCommand), [runCommand]) // biome-ignore lint/correctness/useExhaustiveDependencies: repoRef/busyRef/refreshRef are refs read for their live values, not triggers. useEffect(() => { diff --git a/src/shared/commands.test.ts b/src/shared/commands.test.ts new file mode 100644 index 0000000..40657a6 --- /dev/null +++ b/src/shared/commands.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'bun:test' +import { APP_COMMANDS, appCommand, commandTitle, formatAccelerator } from './commands' + +describe('APP_COMMANDS', () => { + it('has unique ids', () => { + const ids = APP_COMMANDS.map((c) => c.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('never binds the same shortcut twice', () => { + const accelerators = APP_COMMANDS.flatMap((c) => (c.accelerator ? [c.accelerator] : [])) + expect(new Set(accelerators).size).toBe(accelerators.length) + }) +}) + +describe('commandTitle', () => { + it('names the file manager per platform', () => { + expect(commandTitle('reveal-repo', 'darwin')).toBe('Reveal in Finder') + expect(commandTitle('reveal-repo', 'win32')).toBe('Show in Explorer') + expect(commandTitle('reveal-repo', 'linux')).toBe('Open Folder') + }) + + it('uses the registry title otherwise', () => { + expect(commandTitle('fetch', 'linux')).toBe(appCommand('fetch').title) + }) +}) + +describe('formatAccelerator', () => { + it('uses glyphs in canonical order on macOS', () => { + expect(formatAccelerator('CmdOrCtrl+Shift+F', 'darwin')).toBe('⇧⌘F') + expect(formatAccelerator('CmdOrCtrl+K', 'darwin')).toBe('⌘K') + expect(formatAccelerator('Shift+Alt+Ctrl+X', 'darwin')).toBe('⌃⌥⇧X') + }) + + it('spells modifiers out elsewhere', () => { + expect(formatAccelerator('CmdOrCtrl+Shift+F', 'win32')).toBe('Ctrl+Shift+F') + expect(formatAccelerator('CmdOrCtrl+,', 'linux')).toBe('Ctrl+,') + }) + + it('never repeats Ctrl when both spellings are present', () => { + expect(formatAccelerator('CmdOrCtrl+Ctrl+A', 'win32')).toBe('Ctrl+A') + }) +}) diff --git a/src/shared/commands.ts b/src/shared/commands.ts new file mode 100644 index 0000000..4fa691c --- /dev/null +++ b/src/shared/commands.ts @@ -0,0 +1,124 @@ +// The app's command registry: every action a user can run by name — from the +// native application menu (main) and the command palette (renderer) alike. +// One list, so both surfaces show the same wording and the same shortcut, and +// a new action added here is instantly findable in the palette. +// +// A command is data only; *running* it is the renderer's job (see the +// renderer's useAppCommands), because every action lands in renderer state +// (a modal, a tab, a sync). The menu just sends the id over IPC.menuCommand. + +export type AppCommandId = + | 'search-everything' + | 'open-repo' + | 'clone' + | 'settings' + | 'fetch' + | 'pull' + | 'push' + | 'new-branch' + | 'stash' + | 'undo' + | 'optimize' + | 'worktrees' + | 'submodules' + | 'reveal-repo' + | 'open-terminal' + | 'copy-repo-path' + | 'view-on-remote' + | 'show-changes' + | 'show-history' + | 'show-graph' + | 'theme-light' + | 'theme-dark' + | 'theme-system' + | 'check-updates' + | 'about' + +export interface AppCommand { + id: AppCommandId + title: string + /** Electron accelerator syntax (`CmdOrCtrl+Shift+F`) — registered by the + * menu, shown as a hint by the palette. */ + accelerator?: string + /** Only meaningful with a repository open (disabled/hidden otherwise). */ + needsRepo: boolean +} + +export const APP_COMMANDS: readonly AppCommand[] = [ + { + id: 'search-everything', + title: 'Search Everything…', + accelerator: 'CmdOrCtrl+K', + needsRepo: false + }, + { id: 'open-repo', title: 'Open Repository…', accelerator: 'CmdOrCtrl+O', needsRepo: false }, + { id: 'clone', title: 'Clone Repository…', accelerator: 'CmdOrCtrl+Shift+O', needsRepo: false }, + { id: 'settings', title: 'Settings…', accelerator: 'CmdOrCtrl+,', needsRepo: false }, + { id: 'fetch', title: 'Fetch', accelerator: 'CmdOrCtrl+Shift+F', needsRepo: true }, + { id: 'pull', title: 'Pull', accelerator: 'CmdOrCtrl+Shift+P', needsRepo: true }, + { id: 'push', title: 'Push', accelerator: 'CmdOrCtrl+P', needsRepo: true }, + { id: 'new-branch', title: 'New Branch…', accelerator: 'CmdOrCtrl+Shift+N', needsRepo: true }, + { id: 'stash', title: 'Stash All Changes…', needsRepo: true }, + // No accelerator: a global Cmd/Ctrl+Z would hijack text undo in the commit + // composer. The Changes banner is the primary affordance. + { id: 'undo', title: 'Undo Last Action', needsRepo: true }, + { id: 'optimize', title: 'Speed Up Large Repository', needsRepo: true }, + { id: 'worktrees', title: 'Worktrees…', needsRepo: true }, + { id: 'submodules', title: 'Submodules…', needsRepo: true }, + { id: 'reveal-repo', title: 'Reveal Repository Folder', needsRepo: true }, + { id: 'open-terminal', title: 'Open in Terminal', needsRepo: true }, + { id: 'copy-repo-path', title: 'Copy Repository Path', needsRepo: true }, + { id: 'view-on-remote', title: 'View on Remote', needsRepo: true }, + { id: 'show-changes', title: 'Go to Changes', needsRepo: true }, + { id: 'show-history', title: 'Go to History', needsRepo: true }, + { id: 'show-graph', title: 'Go to Graph', needsRepo: true }, + { id: 'theme-light', title: 'Use Light Theme', needsRepo: false }, + { id: 'theme-dark', title: 'Use Dark Theme', needsRepo: false }, + { id: 'theme-system', title: 'Use System Theme', needsRepo: false }, + { id: 'check-updates', title: 'Check for Updates…', needsRepo: false }, + { id: 'about', title: 'About GitGrove', needsRepo: false } +] + +const BY_ID = new Map(APP_COMMANDS.map((command) => [command.id, command])) + +/** The command's display title, with the platform's name for the file manager. */ +export function commandTitle(id: AppCommandId, platform: NodeJS.Platform): string { + if (id === 'reveal-repo') { + if (platform === 'darwin') return 'Reveal in Finder' + if (platform === 'win32') return 'Show in Explorer' + return 'Open Folder' + } + return appCommand(id).title +} + +export function appCommand(id: AppCommandId): AppCommand { + const command = BY_ID.get(id) + if (!command) throw new Error(`Unknown command: ${id}`) + return command +} + +/** macOS modifier glyphs, in the platform's canonical ⌃⌥⇧⌘ order. */ +const MAC_MODIFIERS: [string, string][] = [ + ['Ctrl', '⌃'], + ['Alt', '⌥'], + ['Shift', '⇧'], + ['CmdOrCtrl', '⌘'] +] + +/** + * An Electron accelerator as the platform writes shortcuts: `⇧⌘F` on macOS, + * `Ctrl+Shift+F` elsewhere. + */ +export function formatAccelerator(accelerator: string, platform: NodeJS.Platform): string { + const parts = accelerator.split('+') + const key = parts[parts.length - 1] + const modifiers = new Set(parts.slice(0, -1)) + if (platform === 'darwin') { + const glyphs = MAC_MODIFIERS.filter(([name]) => modifiers.has(name)).map(([, glyph]) => glyph) + return `${glyphs.join('')}${key}` + } + const names = ['CmdOrCtrl', 'Ctrl', 'Alt', 'Shift'] + .filter((name) => modifiers.has(name)) + .map((name) => (name === 'CmdOrCtrl' ? 'Ctrl' : name)) + return [...new Set(names), key].join('+') +} diff --git a/src/shared/fuzzy.ts b/src/shared/fuzzy.ts index c6a0b6f..cae9966 100644 --- a/src/shared/fuzzy.ts +++ b/src/shared/fuzzy.ts @@ -123,7 +123,11 @@ function scoreWindow( } /** Fuzzy-match free text (commands, branch names, …). */ -export function fuzzyMatch(query: string, text: string, folded = foldCase(text)): FuzzyMatch | null { +export function fuzzyMatch( + query: string, + text: string, + folded = foldCase(text) +): FuzzyMatch | null { const positions: number[] = [] const score = scoreWindow(query, text, folded, positions) return score === null ? null : { score, positions } @@ -157,7 +161,11 @@ export function fuzzyMatchPath( } /** Score-only {@link fuzzyMatchPath}. */ -export function fuzzyScorePath(query: string, path: string, folded = foldCase(path)): number | null { +export function fuzzyScorePath( + query: string, + path: string, + folded = foldCase(path) +): number | null { const base = path.lastIndexOf('/') + 1 if (base > 0) { const score = scoreWindow(query, path.slice(base), folded.slice(base), null) diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 8d5b5fc..5d807be 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -2,6 +2,7 @@ // preload script exposes on `window.gitgrove`. Both the main process handlers // and the renderer client import from here so the contract stays in one place. +import type { AppCommandId } from './commands' import type { AddAccountResult, AiBranchNameRequest, @@ -31,6 +32,7 @@ import type { DiffArea, DiffPayload, DiscardItem, + FileSearchResult, GitAvailability, GitIdentity, GlobalIdentity, @@ -44,6 +46,7 @@ import type { PullRequestLookup, RebaseTodoItem, RecentRepo, + RefEntry, RemoteRepo, RemoteRepoPage, RepoHostInfo, @@ -80,6 +83,9 @@ export const IPC = { graphPatchIds: 'repo:graph:patch-ids', graphSquashLandings: 'repo:graph:squash-landings', commitIndex: 'repo:commit:index', + // command palette + refs: 'repo:refs', + searchFiles: 'repo:search:files', fileHistory: 'repo:file-history', blame: 'repo:blame', commitFiles: 'repo:commit:files', @@ -193,9 +199,7 @@ export const IPC = { repoChanged: 'repo:changed', /** Open this repo path in this window (launcher shortcuts reusing a welcome-screen window). */ openRepoRequest: 'repo:open-request', - menuOpenRepo: 'menu:open-repo', - menuShowAbout: 'menu:about', - /** Generic application-menu command (payload: a MenuCommand string). */ + /** An application-menu command for the renderer to run (payload: an AppCommandId). */ menuCommand: 'menu:command', cloneProgress: 'repo:clone-progress', /** A network op needs a credential — show the dialog (CredentialPromptRequest). */ @@ -218,20 +222,6 @@ export const IPC = { windowMaximized: 'window:maximized' } as const -/** Commands the application menu sends to the renderer to act on. */ -export type MenuCommand = - | 'settings' - | 'clone' - | 'fetch' - | 'pull' - | 'push' - | 'new-branch' - | 'undo' - | 'stash' - | 'worktrees' - | 'submodules' - | 'optimize' - export interface GitGroveApi { /** Host platform, resolved synchronously at preload so the UI can branch on it. */ platform: NodeJS.Platform @@ -322,6 +312,15 @@ export interface GitGroveApi { * in `git log HEAD`), so the History list can page far enough to reveal it. * `-1` when `hash` isn't an ancestor of HEAD. */ commitIndex(repoPath: string, hash: string): Promise + /** Every local branch, remote branch and tag, most recently touched first — + * the command palette's ref list. */ + refs(repoPath: string): Promise + /** + * The `limit` tracked files best fuzzy-matching `query`, ranked in main so + * the full path list never crosses IPC. Resolves null when a newer query + * from this window superseded it mid-scan (the caller drops it anyway). + */ + searchFiles(repoPath: string, query: string, limit: number): Promise /** Commits that touched a single file, newest first (follows renames). */ fileHistory(repoPath: string, path: string, ref?: string): Promise /** Per-line authorship for a file; no `ref` blames the working tree. */ @@ -616,12 +615,8 @@ export interface GitGroveApi { * recent landing in this window because it was idling on the welcome screen. */ onOpenRepoRequest(handler: (path: string) => void): () => void - /** Subscribe to the application menu "Open Repository" command. */ - onMenuOpenRepo(handler: () => void): () => void - /** Subscribe to the "About GitGrove" menu command. */ - onShowAbout(handler: () => void): () => void - /** Subscribe to generic application-menu commands (fetch, pull, stash, …). */ - onMenuCommand(handler: (command: MenuCommand) => void): () => void + /** Subscribe to application-menu commands (fetch, pull, stash, …). */ + onMenuCommand(handler: (command: AppCommandId) => void): () => void /** Subscribe to clone progress pushes while a clone runs. */ onCloneProgress(handler: (progress: CloneProgress) => void): () => void /** Subscribe to credential prompts from running network operations. */ diff --git a/src/shared/types.ts b/src/shared/types.ts index dd33e2a..d03751c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -401,6 +401,33 @@ export interface BranchInfo { recent: string[] } +/** A branch or tag, as the command palette lists it. */ +export interface RefEntry { + kind: 'local' | 'remote' | 'tag' + /** Short name: `main`, `origin/main`, `v1.2.0`. */ + name: string + /** The commit it points at (annotated tags peeled to their commit). */ + hash: string + /** Unix seconds: a branch's last commit, an annotated tag's tagging time. */ + date: number +} + +/** One tracked file matching a command-palette query. */ +export interface FileSearchMatch { + /** Repo-relative, `/`-separated (as git reports it on every platform). */ + path: string + score: number + /** Indexes into `path` of the matched characters, for highlighting. */ + positions: number[] +} + +export interface FileSearchResult { + /** The best matches, best first. */ + matches: FileSearchMatch[] + /** How many tracked files matched in all (≥ matches.length). */ + total: number +} + export interface RepoInfo { path: string name: string From 6f5a090a3d82620b59a3d4038f20f219c7823ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 25 Sep 2026 13:35:38 +0200 Subject: [PATCH 3/6] Make context menus keyboard-drivable and share the branch menu ContextMenu focuses its first item on open (ring only via :focus-visible), moves with arrows/Home/End, opens and closes submenus with right/left, and can hang from a trigger button. The branch switcher's local/remote/GitHub menu items move into a shared builder so the command palette can offer the same menu; remote rows gain the Checkout their click already performs. --- src/renderer/src/App.tsx | 2 +- .../src/components/common/ContextMenu.tsx | 96 +++++++++-- .../src/components/common/branchMenuItems.tsx | 157 ++++++++++++++++++ .../src/components/graph/GraphView.tsx | 2 +- .../src/components/toolbar/BranchSwitcher.tsx | 152 +++-------------- .../src/components/toolbar/Toolbar.tsx | 3 +- src/renderer/src/styles/primitives.css | 6 +- 7 files changed, 279 insertions(+), 139 deletions(-) create mode 100644 src/renderer/src/components/common/branchMenuItems.tsx diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index be7b585..429060c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -35,6 +35,7 @@ import { ChangesView } from './components/changes/ChangesView' import type { ComposerDraft } from './components/changes/CommitComposer' import { ConflictPanel } from './components/changes/ConflictPanel' import { useCommitSelections } from './components/changes/useCommitSelections' +import type { BranchAction } from './components/common/branchMenuItems' import type { ContextMenuItem } from './components/common/ContextMenu' import { type DiffMode, DiffViewer } from './components/common/DiffViewer' import { Resizer } from './components/common/Resizer' @@ -51,7 +52,6 @@ import { HistoryView } from './components/history/HistoryView' import { useCommitDetail } from './components/history/useCommitDetail' import { useCommitLog } from './components/history/useCommitLog' import { SettingsDialog } from './components/settings/SettingsDialog' -import type { BranchAction } from './components/toolbar/BranchSwitcher' import { Toolbar } from './components/toolbar/Toolbar' import { useSyncActions } from './components/toolbar/useSyncActions' import { buildCommitSelection, buildStashSelection } from './lib/commit-selection' diff --git a/src/renderer/src/components/common/ContextMenu.tsx b/src/renderer/src/components/common/ContextMenu.tsx index 13c3ac8..153c8e9 100644 --- a/src/renderer/src/components/common/ContextMenu.tsx +++ b/src/renderer/src/components/common/ContextMenu.tsx @@ -1,4 +1,11 @@ -import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { + type KeyboardEvent, + type ReactNode, + useEffect, + useLayoutEffect, + useRef, + useState +} from 'react' import { createPortal } from 'react-dom' export interface ContextMenuItem { @@ -18,6 +25,9 @@ interface Props { /** Viewport coordinates (typically the cursor) to anchor the menu's corner to. */ x: number y: number + /** A trigger to hang the menu from instead (a "⋯" button): opens below it, + * right-aligned to its edge, flipping above when there's no room. */ + anchor?: DOMRect items: ContextMenuItem[] onClose: () => void } @@ -27,14 +37,18 @@ interface Props { * a portal, measures itself once mounted to flip away from the right/bottom * edges, and closes on outside click, a second right-click, or Escape. Items * carrying a `submenu` open a nested panel on hover (one level deep). + * + * Fully keyboard-drivable: the first item takes focus on open (the ring only + * shows for keyboard users — `:focus-visible`), arrows/Home/End move between + * items, → opens a submenu and ← closes it, Enter/Space activate. */ -export function ContextMenu({ x, y, items, onClose }: Props) { +export function ContextMenu({ x, y, anchor, items, onClose }: Props) { useEffect(() => { // Capture-phase + stopPropagation so Escape dismisses *this* menu only — // when the menu is layered over another overlay (e.g. the branch switcher // popover, whose own window-level Escape would otherwise also fire) one // Escape peels just the top layer, leaving the surface beneath open. - const onKey = (e: KeyboardEvent) => { + const onKey = (e: globalThis.KeyboardEvent) => { if (e.key !== 'Escape') return e.stopPropagation() onClose() @@ -53,7 +67,13 @@ export function ContextMenu({ x, y, items, onClose }: Props) { onClose() }} /> - + , document.body ) @@ -63,6 +83,14 @@ export function ContextMenu({ x, y, items, onClose }: Props) { * item's bounds for a submenu (which opens to the item's right). */ type AnchorRect = Pick +/** The enabled items of a panel, in order — the keyboard's stops. */ +function focusableItems(panel: HTMLElement): HTMLButtonElement[] { + // Submenus nest inside their parent's panel; only this panel's own items count. + return [...panel.querySelectorAll('.ctx-menu__item')].filter( + (el) => !el.disabled && el.closest('.ctx-menu') === panel + ) +} + /** * One menu surface — the root menu or a submenu. Positions itself away from the * viewport edges (a submenu opens to the right of its parent item, flipping left @@ -74,19 +102,30 @@ function MenuPanel({ onClose, at, isSub = false, + hangBelow = false, + autoFocus = false, onHoverEnter, - onHoverLeave + onHoverLeave, + onBack }: { items: ContextMenuItem[] onClose: () => void at: AnchorRect isSub?: boolean + /** Hang below `at` (a trigger button), right-aligned, instead of at its corner. */ + hangBelow?: boolean + /** Focus the first item once positioned (root menus; keyboard-opened submenus). */ + autoFocus?: boolean onHoverEnter?: () => void onHoverLeave?: () => void + /** ← inside a submenu: close it and return focus to its parent item. */ + onBack?: () => void }) { const ref = useRef(null) const [pos, setPos] = useState<{ top: number; left: number } | null>(null) const [openIdx, setOpenIdx] = useState(null) + // A submenu opened from the keyboard focuses its first item; by hover it doesn't. + const [subByKeyboard, setSubByKeyboard] = useState(false) const itemEls = useRef>({}) const closeT = useRef | undefined>(undefined) @@ -96,24 +135,55 @@ function MenuPanel({ const { width, height } = el.getBoundingClientRect() const m = 8 // A submenu opens to the right of its parent item (overlapping the border a - // touch), flipping to the left when it would run off-screen; the root opens - // at the cursor. Either way, clamp inside the viewport. - let left = isSub ? at.right - 4 : at.left + // touch), flipping to the left when it would run off-screen; a hanging menu + // drops below its trigger (above when the bottom is too close); the root + // opens at the cursor. Either way, clamp inside the viewport. + let left = isSub ? at.right - 4 : hangBelow ? at.right - width : at.left if (isSub && left + width > window.innerWidth - m) left = at.left - width + 4 - let top = isSub ? at.top - 5 : at.top + let top = isSub ? at.top - 5 : hangBelow ? at.bottom + 4 : at.top + if (hangBelow && top + height > window.innerHeight - m) top = at.top - height - 4 left = Math.max(m, Math.min(left, window.innerWidth - width - m)) top = Math.max(m, Math.min(top, window.innerHeight - height - m)) setPos({ top, left }) - }, [at, isSub]) + }, [at, isSub, hangBelow]) + + useEffect(() => { + if (pos && autoFocus && ref.current) focusableItems(ref.current)[0]?.focus() + }, [pos, autoFocus]) const openSub = (i: number) => { clearTimeout(closeT.current) + setSubByKeyboard(false) setOpenIdx(i) } const scheduleClose = () => { closeT.current = setTimeout(() => setOpenIdx(null), 150) } + const onKeyDown = (e: KeyboardEvent) => { + const panel = ref.current + // Keys inside an open submenu belong to it (it handles them first). + if (!panel || (e.target as HTMLElement).closest('.ctx-menu') !== panel) return + const stops = focusableItems(panel) + const at = stops.indexOf(document.activeElement as HTMLButtonElement) + const move = (i: number) => stops[(i + stops.length) % stops.length]?.focus() + const key = e.key + if (key === 'ArrowDown') move(at + 1) + else if (key === 'ArrowUp') move(at < 0 ? -1 : at - 1) + else if (key === 'Home') move(0) + else if (key === 'End') move(-1) + else if (key === 'ArrowRight' && at >= 0 && stops[at].getAttribute('aria-haspopup')) { + const index = Object.entries(itemEls.current).find(([, el]) => el === stops[at])?.[0] + if (index === undefined) return + setSubByKeyboard(true) + setOpenIdx(Number(index)) + } else if (key === 'ArrowLeft' && onBack) onBack() + else return + // Handled: keep the keys from scrolling or driving the list behind the menu. + e.preventDefault() + e.stopPropagation() + } + return (
{items.map((item, i) => { if (item.label === undefined) { @@ -160,8 +231,13 @@ function MenuPanel({ items={submenu} onClose={onClose} at={itemEls.current[i]!.getBoundingClientRect()} + autoFocus={subByKeyboard} onHoverEnter={() => clearTimeout(closeT.current)} onHoverLeave={scheduleClose} + onBack={() => { + setOpenIdx(null) + itemEls.current[i]?.focus() + }} /> )}
diff --git a/src/renderer/src/components/common/branchMenuItems.tsx b/src/renderer/src/components/common/branchMenuItems.tsx new file mode 100644 index 0000000..9810eef --- /dev/null +++ b/src/renderer/src/components/common/branchMenuItems.tsx @@ -0,0 +1,157 @@ +// The right-click menu for a branch: checkout, merge-into-current, rename, +// delete, copy, and the GitHub group (its PRs, the branch page). A builder +// rather than a component (like commitMenuItems) so the branch switcher and +// the command palette show the exact same menu for the same branch; the +// destructive entries only open confirmation dialogs — App runs the op. + +import { branchUrl, headPullRequestsUrl } from '@shared/git-host-urls' +import { Icon } from '@/lib/icons' +import type { BranchPrs } from '@/lib/pr-order' +import type { ContextMenuItem } from './ContextMenu' + +/** Branch operations that go through App (beyond plain checkout). */ +export type BranchAction = 'new' | 'merge' | 'rename' | 'delete' + +/** What the menu needs to know about the repo's branches. */ +export interface BranchMenuContext { + /** The checked-out branch (disables self-targeting entries), or null. */ + current: string | null + /** Remote branch names (`origin/x`) — a local branch is linkable on the host + * only once it's published. */ + remote: readonly string[] + /** The repo's GitHub web base, or null off GitHub (no GitHub group). */ + githubWebUrl: string | null + /** Head branch → its PRs, as fetched so far. */ + prByBranch?: ReadonlyMap +} + +/** What the menu's entries do. */ +export interface BranchMenuActions { + checkout: (name: string) => void + branchAction: (action: BranchAction, name: string) => void +} + +/** A remote row's head ref: `origin/foo` → `foo`, what a PR's head names. */ +export const remoteHeadRef = (name: string) => name.slice(name.indexOf('/') + 1) + +/** + * The GitHub group for a branch's menu, under a single leading separator (or + * nothing when none apply): one "Open Pull Request #N" entry when the branch + * has a single PR, or a "Pull Requests (N)" submenu listing them (plus a "View + * all on GitHub" entry when the host has more than we fetched) when it has + * several — so the menu never spills 10 rows. Then "View Branch on GitHub" + * when the branch is published. + */ +export function branchGithubItems(name: string, ctx: BranchMenuContext): ContextMenuItem[] { + const entry = ctx.prByBranch?.get(name) + const prs = entry?.prs ?? [] + const total = entry?.total ?? prs.length + const items: ContextMenuItem[] = [] + const openPr = (url: string) => () => window.gitgrove.openExternal(url) + + if (total === 1 && prs.length === 1) { + items.push({ + label: `Open Pull Request #${prs[0].number} on GitHub`, + icon: , + onClick: openPr(prs[0].url) + }) + } else if (prs.length > 0) { + const submenu: ContextMenuItem[] = prs.map((pr) => ({ + label: `Open Pull Request #${pr.number} on GitHub`, + icon: , + onClick: openPr(pr.url) + })) + if (ctx.githubWebUrl && total > prs.length) { + submenu.push( + {}, + { + label: `View all ${total} on GitHub`, + icon: , + onClick: openPr(headPullRequestsUrl(ctx.githubWebUrl, name)) + } + ) + } + items.push({ label: `Pull Requests (${total})`, icon: , submenu }) + } + + // `branch.remote` holds entries like `origin/feature/x`, so comparing the + // part after the remote name avoids offering a link that would 404. + const published = ctx.remote.some((r) => remoteHeadRef(r) === name) + if (ctx.githubWebUrl && published) { + items.push({ + label: 'View Branch on GitHub', + icon: , + onClick: openPr(branchUrl(ctx.githubWebUrl, name)) + }) + } + return items.length > 0 ? [{}, ...items] : [] +} + +const copyNameItem = (name: string): ContextMenuItem => ({ + label: 'Copy Branch Name', + icon: , + onClick: () => window.gitgrove.clipboardWrite(name) +}) + +/** The full menu for a local branch. */ +export function localBranchMenuItems( + name: string, + ctx: BranchMenuContext, + actions: BranchMenuActions +): ContextMenuItem[] { + const isCurrent = name === ctx.current + return [ + { + label: 'Checkout', + icon: , + disabled: isCurrent, + onClick: () => actions.checkout(name) + }, + {}, + { + // The single entry point for bringing a branch in: the dialog offers + // merge, squash AND rebase, each explained, with a conflict preview — + // a bare "rebase onto this" item would duplicate it minus the safety. + label: `Merge into ${ctx.current ?? 'current'}…`, + icon: , + disabled: isCurrent, + onClick: () => actions.branchAction('merge', name) + }, + {}, + { + label: 'Rename…', + icon: , + onClick: () => actions.branchAction('rename', name) + }, + { + label: 'Delete…', + icon: , + danger: true, + disabled: isCurrent, + onClick: () => actions.branchAction('delete', name) + }, + {}, + copyNameItem(name), + ...branchGithubItems(name, ctx) + ] +} + +/** The menu for a remote branch: checkout and copy, plus the GitHub group + * (matched by its bare head ref, just like a local branch). Merge, rename + * and delete are local-branch operations. */ +export function remoteBranchMenuItems( + name: string, + ctx: BranchMenuContext, + actions: Pick +): ContextMenuItem[] { + return [ + { + label: 'Checkout', + icon: , + onClick: () => actions.checkout(name) + }, + {}, + copyNameItem(name), + ...branchGithubItems(remoteHeadRef(name), ctx) + ] +} diff --git a/src/renderer/src/components/graph/GraphView.tsx b/src/renderer/src/components/graph/GraphView.tsx index c9cfc3a..86958bf 100644 --- a/src/renderer/src/components/graph/GraphView.tsx +++ b/src/renderer/src/components/graph/GraphView.tsx @@ -7,8 +7,8 @@ import type { BranchInfo, Commit } from '@shared/types' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { BranchAction } from '@/components/common/branchMenuItems' import { ContextMenu, type ContextMenuItem } from '@/components/common/ContextMenu' -import type { BranchAction } from '@/components/toolbar/BranchSwitcher' import { filterTerms } from '@/lib/commitFilter' import { Icon } from '@/lib/icons' import { usePersistentState } from '@/lib/persist' diff --git a/src/renderer/src/components/toolbar/BranchSwitcher.tsx b/src/renderer/src/components/toolbar/BranchSwitcher.tsx index 78ed693..0c6076b 100644 --- a/src/renderer/src/components/toolbar/BranchSwitcher.tsx +++ b/src/renderer/src/components/toolbar/BranchSwitcher.tsx @@ -1,8 +1,16 @@ -import { branchUrl, headPullRequestsUrl } from '@shared/git-host-urls' import type { BranchInfo, PullRequestInfo } from '@shared/types' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + type BranchAction, + type BranchMenuActions, + type BranchMenuContext, + branchGithubItems, + localBranchMenuItems, + remoteBranchMenuItems, + remoteHeadRef +} from '@/components/common/branchMenuItems' import { ClearButton } from '@/components/common/ClearButton' -import { ContextMenu, type ContextMenuItem } from '@/components/common/ContextMenu' +import { ContextMenu } from '@/components/common/ContextMenu' import { Popover } from '@/components/common/Popover' import { PrGlyph, PrHoverCard } from '@/components/common/PrHoverCard' import { useVirtualScroll, VScrollbar } from '@/components/common/VirtualScroll' @@ -12,9 +20,6 @@ import { Icon } from '@/lib/icons' import { type BranchPrs, hasMultiplePrs } from '@/lib/pr-order' import { useListKeyNav } from '@/lib/useListKeyNav' -/** Branch operations surfaced from the switcher (beyond plain checkout). */ -export type BranchAction = 'new' | 'merge' | 'rename' | 'delete' - /** The `#123` pill marking a branch's most important PR: a state glyph + the * number, tinted for merged (purple) / closed (red). One badge per branch; when * the branch has more than one PR (`stacked`) a second outlined pill peeks @@ -144,7 +149,7 @@ interface Props { /** A row's head-ref name for PR matching. A local row already is it; a remote * row like `origin/foo` maps to `foo` — a remote branch is exactly what a PR's * head ref names, so it's matched and fetched under the bare name. */ -const headRef = (name: string, local: boolean) => (local ? name : name.slice(name.indexOf('/') + 1)) +const headRef = (name: string, local: boolean) => (local ? name : remoteHeadRef(name)) /** Fixed row height used by the virtualizer (must match the inline row height below). */ const ROW_H = 32 @@ -267,114 +272,20 @@ export function BranchSwitcher({ return () => clearTimeout(timer) }, [open, visibleBranches]) - // A local branch is browsable on the host only once it exists on a remote; - // `branch.remote` holds entries like `origin/feature/x`, so comparing the - // part after the remote name avoids offering a link that would 404. - const isPublished = (name: string) => - branch?.remote.some((r) => r.slice(r.indexOf('/') + 1) === name) ?? false - - /** The GitHub group for a branch's menu, under a single leading separator (or - * nothing when none apply): one "Open Pull Request #N" entry when the branch - * has a single PR, or a "Pull Requests (N)" submenu listing them (plus a "View - * all on GitHub" entry when the host has more than we fetched) when it has - * several — so the menu never spills 10 rows. Then "View Branch on GitHub" - * when the branch is published. */ - const githubMenuItems = (name: string): ContextMenuItem[] => { - const entry = prByBranch?.get(name) - const prs = entry?.prs ?? [] - const total = entry?.total ?? prs.length - const items: ContextMenuItem[] = [] - - if (total === 1 && prs.length === 1) { - const pr = prs[0] - items.push({ - label: `Open Pull Request #${pr.number} on GitHub`, - icon: , - onClick: () => window.gitgrove.openExternal(pr.url) - }) - } else if (prs.length > 0) { - const submenu: ContextMenuItem[] = prs.map((pr) => ({ - label: `Open Pull Request #${pr.number} on GitHub`, - icon: , - onClick: () => window.gitgrove.openExternal(pr.url) - })) - if (githubWebUrl && total > prs.length) { - const web = githubWebUrl - submenu.push( - {}, - { - label: `View all ${total} on GitHub`, - icon: , - onClick: () => window.gitgrove.openExternal(headPullRequestsUrl(web, name)) - } - ) - } - items.push({ label: `Pull Requests (${total})`, icon: , submenu }) - } - - if (githubWebUrl && isPublished(name)) { - items.push({ - label: 'View Branch on GitHub', - icon: , - onClick: () => window.gitgrove.openExternal(branchUrl(githubWebUrl, name)) - }) - } - return items.length > 0 ? [{}, ...items] : [] + const menuContext: BranchMenuContext = { + current: branch?.current ?? null, + remote: branch?.remote ?? [], + githubWebUrl, + prByBranch } - - /** The full context menu for a local branch row. */ - const localBranchMenuItems = (name: string) => { - if (!onBranchAction) return [] - return [ - { - label: 'Checkout', - icon: , - disabled: name === branch?.current, - onClick: () => { - setOpen(false) - select(name) - } - }, - {}, - { - // The single entry point for bringing a branch in: the dialog offers - // merge, squash AND rebase, each explained, with a conflict preview — - // a bare "rebase onto this" item would duplicate it minus the safety. - label: `Merge into ${branch?.current ?? 'current'}…`, - icon: , - disabled: name === branch?.current, - onClick: () => { - setOpen(false) - onBranchAction('merge', name) - } - }, - {}, - { - label: 'Rename…', - icon: , - onClick: () => { - setOpen(false) - onBranchAction('rename', name) - } - }, - { - label: 'Delete…', - icon: , - danger: true, - disabled: name === branch?.current, - onClick: () => { - setOpen(false) - onBranchAction('delete', name) - } - }, - {}, - { - label: 'Copy Branch Name', - icon: , - onClick: () => window.gitgrove.clipboardWrite(name) - }, - ...githubMenuItems(name) - ] + // Every action dismisses the switcher first: it runs in App, which may open + // a dialog the popover would otherwise sit on top of. + const menuActions: BranchMenuActions = { + checkout: select, + branchAction: (action, name) => { + setOpen(false) + onBranchAction?.(action, name) + } } const label = switching @@ -571,7 +482,7 @@ export function BranchSwitcher({ icon: , onClick: () => onBranchAction('rename', branch.current) }, - ...githubMenuItems(branch.current) + ...branchGithubItems(branch.current, menuContext) ]} /> )} @@ -583,17 +494,8 @@ export function BranchSwitcher({ onClose={() => setMenu(null)} items={ menu.local - ? localBranchMenuItems(menu.name) - : [ - { - label: 'Copy Branch Name', - icon: , - onClick: () => window.gitgrove.clipboardWrite(menu.name) - }, - // A remote row matches PRs by its bare ref, so its menu links to - // them (and to the branch on the host) just like a local one. - ...githubMenuItems(headRef(menu.name, false)) - ] + ? localBranchMenuItems(menu.name, menuContext, menuActions) + : remoteBranchMenuItems(menu.name, menuContext, menuActions) } /> )} diff --git a/src/renderer/src/components/toolbar/Toolbar.tsx b/src/renderer/src/components/toolbar/Toolbar.tsx index 07739f8..d5a0782 100644 --- a/src/renderer/src/components/toolbar/Toolbar.tsx +++ b/src/renderer/src/components/toolbar/Toolbar.tsx @@ -1,10 +1,11 @@ import type { BranchInfo, RepoSummary, SyncStatus } from '@shared/types' import { useState } from 'react' +import type { BranchAction } from '@/components/common/branchMenuItems' import { Icon } from '@/lib/icons' import { isMac } from '@/lib/platform' import type { BranchPrs } from '@/lib/pr-order' import type { ResolvedTheme, ThemePref } from '@/lib/theme' -import { type BranchAction, BranchSwitcher } from './BranchSwitcher' +import { BranchSwitcher } from './BranchSwitcher' import { MenuBar } from './MenuBar' import { RepoSwitcher } from './RepoSwitcher' import { type SyncAction, SyncButton } from './SyncButton' diff --git a/src/renderer/src/styles/primitives.css b/src/renderer/src/styles/primitives.css index 73e5771..40d9a4c 100644 --- a/src/renderer/src/styles/primitives.css +++ b/src/renderer/src/styles/primitives.css @@ -310,8 +310,12 @@ font-size: 12.5px; color: var(--fg); } -.ctx-menu__item:hover:not(:disabled) { +.ctx-menu__item:hover:not(:disabled), +/* The keyboard cursor (ContextMenu.tsx focuses items for arrow navigation). + :focus-visible, so a right-click's programmatic focus shows nothing. */ +.ctx-menu__item:focus-visible { background: var(--bg-hover); + outline: none; } .ctx-menu__item:disabled { opacity: 0.45; From 01811dca953a46354843dac5f61c0db0ee96a6d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 25 Sep 2026 13:49:43 +0200 Subject: [PATCH 4/6] Add palette primitives: position highlights, key-cap shortcuts, More icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit highlightPositions marks fuzzy-match positions (with an offset for showing one slice of a path); acceleratorKeys + draw a shortcut as one key cap per key (⌘ K, Ctrl Shift F) on the shared .kbd primitive. --- .../src/components/common/Shortcut.tsx | 20 +++++++++++ src/renderer/src/lib/highlight.test.tsx | 24 ++++++++++++- src/renderer/src/lib/highlight.tsx | 36 +++++++++++++++++++ src/renderer/src/lib/icons.tsx | 8 +++++ src/renderer/src/styles/primitives.css | 25 +++++++++++++ src/shared/commands.test.ts | 16 ++++----- src/shared/commands.ts | 11 +++--- 7 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/common/Shortcut.tsx diff --git a/src/renderer/src/components/common/Shortcut.tsx b/src/renderer/src/components/common/Shortcut.tsx new file mode 100644 index 0000000..fd831ce --- /dev/null +++ b/src/renderer/src/components/common/Shortcut.tsx @@ -0,0 +1,20 @@ +// A keyboard shortcut drawn as one key cap per key (⌘ K, Ctrl Shift F), the +// way the platform's own menus and docs show them. Shared by the toolbar's +// search trigger and the command palette's hints. +// styles: styles/primitives.css (.kbd, .shortcut) + +import { acceleratorKeys } from '@shared/commands' +import { platform } from '@/lib/platform' + +/** `accelerator` in Electron syntax (`CmdOrCtrl+K`). */ +export function Shortcut({ accelerator }: { accelerator: string }) { + return ( + + {acceleratorKeys(accelerator, platform).map((key) => ( + + {key} + + ))} + + ) +} diff --git a/src/renderer/src/lib/highlight.test.tsx b/src/renderer/src/lib/highlight.test.tsx index 65b5fba..d88666f 100644 --- a/src/renderer/src/lib/highlight.test.tsx +++ b/src/renderer/src/lib/highlight.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test' import { isValidElement, type ReactNode } from 'react' -import { highlightMatch, highlightTerms } from './highlight' +import { highlightMatch, highlightPositions, highlightTerms } from './highlight' // Flatten a highlight result into ordered text/marked tokens so we can assert on // what's highlighted without rendering to a DOM. @@ -59,3 +59,25 @@ describe('highlightTerms', () => { expect(highlightTerms('gitgrove', ['zzz'])).toBe('gitgrove') }) }) + +describe('highlightPositions', () => { + it('returns the plain text untouched without positions', () => { + expect(highlightPositions('GraphView', [])).toBe('GraphView') + }) + + it('merges adjacent positions into one mark', () => { + expect(tokens(highlightPositions('GraphView', [0, 5, 6, 7, 8]))).toEqual([ + { text: 'G', marked: true }, + { text: 'raph', marked: false }, + { text: 'View', marked: true } + ]) + }) + + it('shifts by the offset and ignores positions outside the slice', () => { + // "src/App.tsx" matched at 0 (the folder) and 4 (the file name); show the name alone. + expect(tokens(highlightPositions('App.tsx', [0, 4], 4))).toEqual([ + { text: 'A', marked: true }, + { text: 'pp.tsx', marked: false } + ]) + }) +}) diff --git a/src/renderer/src/lib/highlight.tsx b/src/renderer/src/lib/highlight.tsx index 73f0525..5b897ae 100644 --- a/src/renderer/src/lib/highlight.tsx +++ b/src/renderer/src/lib/highlight.tsx @@ -52,3 +52,39 @@ export function highlightTerms(text: string, terms: string[]): ReactNode { if (from < text.length) parts.push(text.slice(from)) return parts } + +/** + * Fuzzy-match highlight: wrap the characters at `positions` (indexes into + * `text`, ascending — what shared/fuzzy reports), merging runs of adjacent + * positions into one . `offset` shifts the positions first, for showing + * one slice of the matched string (a path's file name apart from its folder). + */ +export function highlightPositions( + text: string, + positions: readonly number[], + offset = 0 +): ReactNode { + const parts: ReactNode[] = [] + let from = 0 + let i = 0 + while (i < positions.length) { + const start = positions[i] - offset + let end = start + 1 + while (i + 1 < positions.length && positions[i + 1] - offset === end) { + end++ + i++ + } + i++ + if (start < 0 || start >= text.length) continue + if (start > from) parts.push(text.slice(from, start)) + parts.push( + + {text.slice(start, Math.min(end, text.length))} + + ) + from = Math.min(end, text.length) + } + if (parts.length === 0) return text + if (from < text.length) parts.push(text.slice(from)) + return parts +} diff --git a/src/renderer/src/lib/icons.tsx b/src/renderer/src/lib/icons.tsx index 1896a68..21cd43d 100644 --- a/src/renderer/src/lib/icons.tsx +++ b/src/renderer/src/lib/icons.tsx @@ -183,6 +183,14 @@ export const Icon = { ), + /** Horizontal ellipsis — a row's "more actions" trigger. */ + More: (p: IconProps) => ( + + + + + + ), Check: (p: IconProps) => ( diff --git a/src/renderer/src/styles/primitives.css b/src/renderer/src/styles/primitives.css index 40d9a4c..6f2f58a 100644 --- a/src/renderer/src/styles/primitives.css +++ b/src/renderer/src/styles/primitives.css @@ -792,6 +792,31 @@ background: var(--bg-elevated); box-shadow: var(--popover-shadow); } +/* Keyboard shortcut chip (the toolbar's search trigger, the command palette's + shortcut hints and key legend). */ +.kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--fg-faint); + font-family: inherit; + font-size: 10.5px; + font-weight: 500; + line-height: 1; + white-space: nowrap; +} +/* A shortcut as separate key caps (Shortcut.tsx). */ +.shortcut { + display: inline-flex; + flex: 0 0 auto; + gap: 3px; +} /* Matched substring inside a filtered list item (files, branches, repos, commits). */ .hl { diff --git a/src/shared/commands.test.ts b/src/shared/commands.test.ts index 40657a6..1a031d0 100644 --- a/src/shared/commands.test.ts +++ b/src/shared/commands.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test' -import { APP_COMMANDS, appCommand, commandTitle, formatAccelerator } from './commands' +import { APP_COMMANDS, acceleratorKeys, appCommand, commandTitle } from './commands' describe('APP_COMMANDS', () => { it('has unique ids', () => { @@ -25,19 +25,19 @@ describe('commandTitle', () => { }) }) -describe('formatAccelerator', () => { +describe('acceleratorKeys', () => { it('uses glyphs in canonical order on macOS', () => { - expect(formatAccelerator('CmdOrCtrl+Shift+F', 'darwin')).toBe('⇧⌘F') - expect(formatAccelerator('CmdOrCtrl+K', 'darwin')).toBe('⌘K') - expect(formatAccelerator('Shift+Alt+Ctrl+X', 'darwin')).toBe('⌃⌥⇧X') + expect(acceleratorKeys('CmdOrCtrl+Shift+F', 'darwin')).toEqual(['⇧', '⌘', 'F']) + expect(acceleratorKeys('CmdOrCtrl+K', 'darwin')).toEqual(['⌘', 'K']) + expect(acceleratorKeys('Shift+Alt+Ctrl+X', 'darwin')).toEqual(['⌃', '⌥', '⇧', 'X']) }) it('spells modifiers out elsewhere', () => { - expect(formatAccelerator('CmdOrCtrl+Shift+F', 'win32')).toBe('Ctrl+Shift+F') - expect(formatAccelerator('CmdOrCtrl+,', 'linux')).toBe('Ctrl+,') + expect(acceleratorKeys('CmdOrCtrl+Shift+F', 'win32')).toEqual(['Ctrl', 'Shift', 'F']) + expect(acceleratorKeys('CmdOrCtrl+,', 'linux')).toEqual(['Ctrl', ',']) }) it('never repeats Ctrl when both spellings are present', () => { - expect(formatAccelerator('CmdOrCtrl+Ctrl+A', 'win32')).toBe('Ctrl+A') + expect(acceleratorKeys('CmdOrCtrl+Ctrl+A', 'win32')).toEqual(['Ctrl', 'A']) }) }) diff --git a/src/shared/commands.ts b/src/shared/commands.ts index 4fa691c..7b1b021 100644 --- a/src/shared/commands.ts +++ b/src/shared/commands.ts @@ -106,19 +106,18 @@ const MAC_MODIFIERS: [string, string][] = [ ] /** - * An Electron accelerator as the platform writes shortcuts: `⇧⌘F` on macOS, - * `Ctrl+Shift+F` elsewhere. + * An Electron accelerator's keys as the platform names them, one per key cap: + * `['⇧', '⌘', 'F']` on macOS, `['Ctrl', 'Shift', 'F']` elsewhere. */ -export function formatAccelerator(accelerator: string, platform: NodeJS.Platform): string { +export function acceleratorKeys(accelerator: string, platform: NodeJS.Platform): string[] { const parts = accelerator.split('+') const key = parts[parts.length - 1] const modifiers = new Set(parts.slice(0, -1)) if (platform === 'darwin') { - const glyphs = MAC_MODIFIERS.filter(([name]) => modifiers.has(name)).map(([, glyph]) => glyph) - return `${glyphs.join('')}${key}` + return [...MAC_MODIFIERS.filter(([name]) => modifiers.has(name)).map(([, glyph]) => glyph), key] } const names = ['CmdOrCtrl', 'Ctrl', 'Alt', 'Shift'] .filter((name) => modifiers.has(name)) .map((name) => (name === 'CmdOrCtrl' ? 'Ctrl' : name)) - return [...new Set(names), key].join('+') + return [...new Set(names), key] } From 720439760f5d3612ef6f68a815516331c03f539e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 25 Sep 2026 13:49:43 +0200 Subject: [PATCH 5/6] Add the command palette (Cmd/Ctrl+K) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One box that searches commands, branches, files, tags, stashes, worktrees, recent repositories and pasted commit ids, in fixed-order sections with "N more" expansion and >, @, / scope prefixes. - Enter reveals: a branch or tag is selected and framed in the Graph (GraphView.revealRequest; a miss clears the session's own filters and retries, commits fall back to History), a changed file opens in Changes, any other file its history, a stash its review. - Tab, right-click or the row's ⋯ open the object's own menu (shared branch/commit/file builders), so acting never needs the reveal. - Files are fuzzy-ranked in main; changed and untracked files merge in from the snapshot. Ranking, scoping and limits are pure and tested. - A quiet Search field with the shortcut in the toolbar for discovery; new delete-tag confirmation for the tag menu. --- src/renderer/src/App.tsx | 75 ++++ src/renderer/src/components/app/AppModals.tsx | 18 + .../src/components/graph/GraphView.tsx | 43 +++ .../src/components/graph/reveal.test.ts | 67 ++++ src/renderer/src/components/graph/reveal.ts | 43 +++ .../src/components/palette/PaletteRow.tsx | 161 ++++++++ .../src/components/palette/SearchPalette.tsx | 343 ++++++++++++++++++ .../src/components/palette/paletteActions.tsx | 235 ++++++++++++ .../components/palette/paletteResults.test.ts | 175 +++++++++ .../src/components/palette/paletteResults.ts | 329 +++++++++++++++++ .../components/palette/usePaletteSources.ts | 98 +++++ .../src/components/toolbar/Toolbar.tsx | 18 +- src/renderer/src/styles/features/palette.css | 223 ++++++++++++ src/renderer/src/styles/features/toolbar.css | 27 ++ src/renderer/src/styles/global.css | 1 + 15 files changed, 1855 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/graph/reveal.test.ts create mode 100644 src/renderer/src/components/graph/reveal.ts create mode 100644 src/renderer/src/components/palette/PaletteRow.tsx create mode 100644 src/renderer/src/components/palette/SearchPalette.tsx create mode 100644 src/renderer/src/components/palette/paletteActions.tsx create mode 100644 src/renderer/src/components/palette/paletteResults.test.ts create mode 100644 src/renderer/src/components/palette/paletteResults.ts create mode 100644 src/renderer/src/components/palette/usePaletteSources.ts create mode 100644 src/renderer/src/styles/features/palette.css diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 429060c..f1d6154 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -44,6 +44,7 @@ import { TooltipLayer } from './components/common/TooltipLayer' import { GraphDetailPane } from './components/graph/GraphDetailPane' import { GraphView } from './components/graph/GraphView' import { branchKey, type GraphRow } from './components/graph/layout' +import type { GraphRevealRequest, GraphRevealTarget } from './components/graph/reveal' import { useBranchRange } from './components/graph/useBranchRange' import { CommitSummary } from './components/history/CommitSummary' import { commitMenuItems } from './components/history/commitMenuItems' @@ -51,6 +52,7 @@ import { FileHistoryOverlay, type FileHistoryTarget } from './components/history import { HistoryView } from './components/history/HistoryView' import { useCommitDetail } from './components/history/useCommitDetail' import { useCommitLog } from './components/history/useCommitLog' +import { SearchPalette } from './components/palette/SearchPalette' import { SettingsDialog } from './components/settings/SettingsDialog' import { Toolbar } from './components/toolbar/Toolbar' import { useSyncActions } from './components/toolbar/useSyncActions' @@ -544,6 +546,49 @@ export function App() { ] ) + // ── Reveal from the command palette ──────────────────────────────────────── + // A branch or tag asked for from the palette is shown in the Graph: switch + // there and hand GraphView the target; it selects and frames it once loaded + // (see its revealRequest). The nonce makes revealing the same thing twice fire. + const [graphReveal, setGraphReveal] = useState(null) + const revealNonce = useRef(0) + const revealInGraph = useCallback( + (target: GraphRevealTarget) => { + setFileHistory(null) + switchTab('graph') + setGraphReveal({ target, nonce: ++revealNonce.current }) + }, + [switchTab] + ) + + /** The Graph couldn't show the target: a commit falls back to History (which + * pages as deep as it takes); a branch gets an explanation. */ + const onGraphRevealMissed = useCallback( + ({ target }: GraphRevealRequest) => { + if (target.kind === 'commit') revealCommit(target.hash) + else + setNotice( + `${target.name} isn’t in the graph’s current view — the view options may hide it, ` + + 'or it’s older than the history loaded.' + ) + }, + [revealCommit] + ) + + /** A file from the palette: its pending change in Changes, else its history. */ + const openPaletteFile = useCallback( + (path: string, change: ChangedFile | null) => { + setFileHistory(null) + if (!change) { + openFileHistory(path, 'diff', null) + return + } + setTab('changes') + selectWorkingFile(path, undefined, { force: true }) + }, + [openFileHistory, selectWorkingFile] + ) + // ── Refresh: pulls every panel up to date (watcher + post-op) ───────────── // biome-ignore lint/correctness/useExhaustiveDependencies: logLoadedRef is a ref read for its live value, not a trigger. const refresh = useCallback(async () => { @@ -1428,6 +1473,32 @@ export function App() { /> )} {modals} + setSearchOpen(false)} + commands={availableCommands} + repoPath={repoPath ?? null} + currentBranch={branch && !branch.detached ? branch.current : null} + changes={changes} + stashes={stashes} + githubWebUrl={githubWebUrl} + prByBranch={prByBranch} + theme={theme} + actions={{ + repoPath: repoPath ?? null, + runCommand, + revealInGraph, + revealCommit: (commit) => revealCommit(commit.hash), + openFile: openPaletteFile, + openFileHistory: (path, mode) => openFileHistory(path, mode, null), + openRepo: openRepoByPath, + checkout, + branchAction: onBranchAction, + openModal: setModal, + commitMenuFor, + runOp + }} + /> ) @@ -1453,6 +1524,7 @@ export function App() { onRefresh={refresh} onThemeChange={setThemePref} onAbout={() => setAboutOpen(true)} + onSearch={() => setSearchOpen(true)} />
{git === null ? ( @@ -1513,6 +1585,7 @@ export function App() { onRefresh={refresh} onThemeChange={setThemePref} onAbout={() => setAboutOpen(true)} + onSearch={() => setSearchOpen(true)} />
switchTab('changes')} + revealRequest={graphReveal} + onRevealMissed={onGraphRevealMissed} onError={fail} /> {tab === 'graph' && (selectedCommit || branchRange) && ( diff --git a/src/renderer/src/components/app/AppModals.tsx b/src/renderer/src/components/app/AppModals.tsx index 980743c..06678cb 100644 --- a/src/renderer/src/components/app/AppModals.tsx +++ b/src/renderer/src/components/app/AppModals.tsx @@ -27,6 +27,7 @@ export type Modal = | { kind: 'rename-branch'; name: string } | { kind: 'delete-branch'; name: string; force: boolean } | { kind: 'create-tag'; hash: string; shortHash: string } + | { kind: 'delete-tag'; name: string } | { kind: 'reset'; hash: string; shortHash: string; mode: ResetMode } | { kind: 'revert'; hash: string; shortHash: string } | { kind: 'checkout-commit'; hash: string; shortHash: string } @@ -189,6 +190,23 @@ export function AppModals({ onCancel={onClose} /> ) + case 'delete-tag': + return ( + + The local tag {modal.name} will be deleted. A copy already pushed to a + remote is untouched. + + } + confirmLabel="Delete" + onConfirm={() => runModalOp(() => gg.deleteTag(repoPath, modal.name))} + onCancel={onClose} + /> + ) case 'reset': return ( ) => void + /** Select and frame a branch or commit asked for from elsewhere (the + * command palette), once the graph has loaded. */ + revealRequest: GraphRevealRequest | null + /** The requested target isn't in the loaded graph, even unfiltered. */ + onRevealMissed: (request: GraphRevealRequest) => void onError: (e: unknown) => void } @@ -94,6 +100,8 @@ export function GraphView({ prByBranch, onNeedPrs, onRowPrsChange, + revealRequest, + onRevealMissed, onError }: Props) { const [branchFilter, setBranchFilter] = useState | null>(null) @@ -284,6 +292,41 @@ export function GraphView({ } }, [activeHit, layout]) + // A reveal from elsewhere lands once the graph has settled on fresh data. + // A miss first drops this session's own filters (focus, branch/author/date + // picks) — they're what most likely hides the target — and retries on the + // re-laid-out graph; only a miss with nothing left to clear is reported. + // The persisted view options (structure only, hide merged) are the user's + // standing choice and stay as they are. + const handledRevealRef = useRef(null) + // biome-ignore lint/correctness/useExhaustiveDependencies: fires on the request, and re-tries as the layout changes; the callbacks are read live. + useEffect(() => { + if (!revealRequest || !active || !loaded || loading) return + if (handledRevealRef.current === revealRequest.nonce) return + const hit = resolveReveal(revealRequest.target, layout, remotes) + if (hit?.kind === 'row') { + handledRevealRef.current = revealRequest.nonce + onSelectBranch(hit.row) + controls.current?.revealAt(hit.row.startColumn, hit.row.index) + return + } + if (hit?.kind === 'node') { + handledRevealRef.current = revealRequest.nonce + onSelectCommit(hit.node.commit) + controls.current?.reveal(hit.node.commit.hash) + return + } + if (branchFilter || focus || authorFilter || datePreset !== 'all') { + setFocus(null) + setBranchFilter(null) + setAuthorFilter(null) + setDatePreset('all') + return + } + handledRevealRef.current = revealRequest.nonce + onRevealMissed(revealRequest) + }, [revealRequest, active, loaded, loading, layout]) + const stepMatch = (dir: 1 | -1) => { if (matchList.length === 0) return setMatchIndex((i) => i + dir) diff --git a/src/renderer/src/components/graph/reveal.test.ts b/src/renderer/src/components/graph/reveal.test.ts new file mode 100644 index 0000000..5364a2b --- /dev/null +++ b/src/renderer/src/components/graph/reveal.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' +import type { Commit } from '@shared/types' +import { type GraphInput, layoutGraph } from './layout' +import { resolveReveal } from './reveal' + +function commit(hash: string, parents: string[], refs = ''): Commit { + return { + hash, + shortHash: hash.slice(0, 7), + subject: `subject ${hash}`, + body: '', + authorName: 'Ada', + authorEmail: 'ada@example.com', + date: '2026-07-01T10:00:00+00:00', + relativeDate: 'now', + refs, + parents + } +} + +function input(commits: Commit[]): GraphInput { + return { + commits, + remotes: ['origin'], + headBranch: 'main', + detached: false, + defaultBranch: 'main' + } +} + +// main: a ── b feature (and origin/feature): a ── f +const layout = layoutGraph( + input([ + commit('b', ['a'], 'HEAD -> main'), + commit('f', ['a'], 'feature, origin/feature'), + commit('a', []) + ]) +) + +describe('resolveReveal', () => { + test('a local branch resolves to its row', () => { + const hit = resolveReveal({ kind: 'branch', name: 'feature', hash: 'f' }, layout, ['origin']) + expect(hit?.kind === 'row' && hit.row.name).toBe('feature') + }) + + test('a remote branch resolves to the row it shares with its local twin', () => { + const hit = resolveReveal({ kind: 'branch', name: 'origin/feature', hash: 'f' }, layout, [ + 'origin' + ]) + expect(hit?.kind === 'row' && hit.row.tipHash).toBe('f') + }) + + test('a branch without a row falls back to its tip commit', () => { + const hit = resolveReveal({ kind: 'branch', name: 'gone', hash: 'b' }, layout, ['origin']) + expect(hit?.kind === 'node' && hit.node.commit.hash).toBe('b') + }) + + test('a commit resolves to its node', () => { + const hit = resolveReveal({ kind: 'commit', hash: 'a' }, layout, ['origin']) + expect(hit?.kind === 'node' && hit.node.commit.hash).toBe('a') + }) + + test('anything outside the loaded window is a miss', () => { + expect(resolveReveal({ kind: 'commit', hash: 'zzz' }, layout, ['origin'])).toBeNull() + expect(resolveReveal({ kind: 'branch', name: 'old', hash: 'zzz' }, layout, [])).toBeNull() + }) +}) diff --git a/src/renderer/src/components/graph/reveal.ts b/src/renderer/src/components/graph/reveal.ts new file mode 100644 index 0000000..1bc6ea7 --- /dev/null +++ b/src/renderer/src/components/graph/reveal.ts @@ -0,0 +1,43 @@ +// Revealing something in the Graph from elsewhere (the command palette): which +// row or node a branch/commit target lands on in the current layout. Pure, so +// the matching rules are tested without a canvas. + +import type { GraphLayout, GraphNode, GraphRow } from './layout' + +export type GraphRevealTarget = + /** A branch ref by its full short name (`feature/x`, `origin/feature/x`) + * and the commit it points at. */ + { kind: 'branch'; name: string; hash: string } | { kind: 'commit'; hash: string } + +/** A reveal request; the nonce makes asking for the same target again fire. */ +export interface GraphRevealRequest { + target: GraphRevealTarget + nonce: number +} + +export type RevealHit = { kind: 'row'; row: GraphRow } | { kind: 'node'; node: GraphNode } + +/** + * Where `target` is in `layout`, or null when the loaded window doesn't show + * it. A branch resolves to its row — rows are named by base name (`origin/x` + * and `x` share one), and when a diverged branch has several rows of that + * name, the one whose tip is the ref's commit wins. A branch whose row is + * gone but whose tip commit is still drawn falls back to that commit. + */ +export function resolveReveal( + target: GraphRevealTarget, + layout: GraphLayout, + remotes: readonly string[] +): RevealHit | null { + if (target.kind === 'branch') { + const remote = remotes.find((r) => target.name.startsWith(`${r}/`)) + const base = remote ? target.name.slice(remote.length + 1) : target.name + const named = layout.rows.filter( + (row) => row.name === base && (row.kind === 'branch' || row.kind === 'remote') + ) + const row = named.find((r) => r.tipHash === target.hash) ?? named[0] + if (row) return { kind: 'row', row } + } + const node = layout.nodeByHash.get(target.hash) + return node ? { kind: 'node', node } : null +} diff --git a/src/renderer/src/components/palette/PaletteRow.tsx b/src/renderer/src/components/palette/PaletteRow.tsx new file mode 100644 index 0000000..eaabefa --- /dev/null +++ b/src/renderer/src/components/palette/PaletteRow.tsx @@ -0,0 +1,161 @@ +// One command-palette result: a kind glyph, the highlighted title (and +// subtitle), a trailing badge or shortcut hint, and — for results with +// actions — a "⋯" button that opens the same menu as a right-click. +// styles: styles/features/palette.css + +import type { ReactNode } from 'react' +import { Shortcut } from '@/components/common/Shortcut' +import { splitPath, statusLabel, statusLetter } from '@/lib/format' +import { highlightPositions } from '@/lib/highlight' +import { Icon } from '@/lib/icons' +import type { PaletteHit } from './paletteResults' + +interface Props { + hit: PaletteHit + /** Index in the palette's keyboard order (`data-entry`, for scrolling). */ + entry: number + selected: boolean + /** This row's menu is open (keeps it highlighted like the switchers do). */ + menuOpen: boolean + hasMenu: boolean + onHover: () => void + onActivate: () => void + /** Open the row's menu at a point (right-click) or hung from the ⋯ button. */ + onMenu: (at: { x: number; y: number } | DOMRect) => void +} + +function glyph(hit: PaletteHit): ReactNode { + const { item } = hit + switch (item.kind) { + case 'command': + return + case 'branch': + return + case 'tag': + return + case 'file': + return + case 'commit': + return + case 'stash': + return + case 'place': + return item.place === 'worktree' ? : + } +} + +/** Title, optional subtitle and trailing adornment for a hit. */ +function content(hit: PaletteHit): { title: ReactNode; sub?: ReactNode; trail?: ReactNode } { + const { item, highlights } = hit + switch (item.kind) { + case 'command': + return { + title: highlightPositions(item.title, highlights[0]), + trail: item.accelerator && + } + case 'branch': + return { + title: highlightPositions(item.ref.name, highlights[0]), + trail: item.current ? ( + current + ) : item.ref.kind === 'remote' ? ( + remote + ) : undefined + } + case 'tag': + return { title: highlightPositions(item.ref.name, highlights[0]) } + case 'file': { + // File name first, its folder after it — the name is what you scan for. + const { dir, name } = splitPath(item.path) + const offset = item.path.length - name.length + return { + title: highlightPositions(name, highlights[0], offset), + sub: dir && highlightPositions(dir, highlights[0]), + trail: item.change && ( + + {statusLetter(item.change.status)} + + ) + } + } + case 'commit': + return { + title: item.commit.subject, + sub: ( + <> + + {highlightPositions(item.commit.shortHash, highlights[0])} + {' '} + · {item.commit.authorName} · {item.commit.relativeDate} + + ) + } + case 'stash': + return { + title: highlightPositions(item.label, highlights[0]), + sub: item.stash.relativeDate + } + case 'place': + return { + title: highlightPositions(item.name, highlights[0]), + sub: highlightPositions(item.path, highlights[1]) + } + } +} + +export function PaletteRow({ + hit, + entry, + selected, + menuOpen, + hasMenu, + onHover, + onActivate, + onMenu +}: Props) { + const { title, sub, trail } = content(hit) + return ( +
{ + e.preventDefault() + onMenu({ x: e.clientX, y: e.clientY }) + } + : undefined + } + > + {glyph(hit)} + + {title} + {sub && {sub}} + + {trail} + {hasMenu && ( + + )} +
+ ) +} diff --git a/src/renderer/src/components/palette/SearchPalette.tsx b/src/renderer/src/components/palette/SearchPalette.tsx new file mode 100644 index 0000000..020782f --- /dev/null +++ b/src/renderer/src/components/palette/SearchPalette.tsx @@ -0,0 +1,343 @@ +// The command palette (Cmd/Ctrl+K): one box that searches everything — +// commands, branches, files, tags, stashes, worktrees, recent repositories and +// pasted commit ids. Enter reveals a result where it lives; Tab, right-click or +// the row's ⋯ open its full menu, so acting on something never requires +// navigating to it first. `>`, `@` and `/` narrow to commands, refs and files. +// +// The input keeps focus the whole time and owns the keyboard: arrows move the +// highlight through every section in order, Enter activates, Tab opens the +// highlighted row's menu (which then takes the keyboard until it closes). +// styles: styles/features/palette.css + +import type { AppCommand } from '@shared/commands' +import { commandTitle } from '@shared/commands' +import type { ChangedFile, StashEntry } from '@shared/types' +import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react' +import { StashReviewDialog } from '@/components/changes/StashReviewDialog' +import type { BranchMenuContext } from '@/components/common/branchMenuItems' +import { ClearButton } from '@/components/common/ClearButton' +import { ContextMenu, type ContextMenuItem } from '@/components/common/ContextMenu' +import { Icon } from '@/lib/icons' +import { platform } from '@/lib/platform' +import type { BranchPrs } from '@/lib/pr-order' +import type { ResolvedTheme } from '@/lib/theme' +import { navTarget } from '@/lib/useListKeyNav' +import { PaletteRow } from './PaletteRow' +import { activateItem, closingFirst, itemMenu, type PaletteActions } from './paletteActions' +import { buildSections, type PaletteHit, type SectionId } from './paletteResults' +import { usePaletteSources } from './usePaletteSources' + +/** Rows per PageUp/PageDown jump. */ +const PAGE = 8 + +interface Props { + open: boolean + onClose: () => void + /** The commands available right now (useAppCommands). */ + commands: () => AppCommand[] + repoPath: string | null + currentBranch: string | null + changes: ChangedFile[] + stashes: StashEntry[] + githubWebUrl: string | null + prByBranch: ReadonlyMap + theme: ResolvedTheme + actions: Omit +} + +/** One keyboard stop: a result, or a section's "N more" row. */ +type Entry = { kind: 'hit'; hit: PaletteHit } | { kind: 'more'; section: SectionId } + +export function SearchPalette(props: Props) { + // The stash review outlives the palette: it opens as the palette closes. + const [reviewing, setReviewing] = useState(null) + const { repoPath, actions, theme } = props + return ( + <> + {props.open && ( + + )} + {reviewing && repoPath && ( + { + setReviewing(null) + actions.runOp(() => window.gitgrove.stashApply(repoPath, reviewing.index, pop)) + }} + onDrop={() => { + setReviewing(null) + actions.runOp(() => window.gitgrove.stashDrop(repoPath, reviewing.index)) + }} + onClose={() => setReviewing(null)} + /> + )} + + ) +} + +function PaletteDialog({ + onClose, + commands, + repoPath, + currentBranch, + changes, + stashes, + githubWebUrl, + prByBranch, + actions +}: Omit & { actions: PaletteActions }) { + const [query, setQuery] = useState('') + const [expanded, setExpanded] = useState>(() => new Set()) + const [index, setIndex] = useState(0) + // The open row menu: at the cursor (right-click) or hung from a ⋯ (anchor). + const [menu, setMenu] = useState<{ + key: string + items: ContextMenuItem[] + x: number + y: number + anchor?: DOMRect + } | null>(null) + const inputRef = useRef(null) + const listRef = useRef(null) + + const loaded = usePaletteSources(repoPath, query) + // The command list is fixed for the palette's lifetime (state can't change + // underneath an open modal), so it's resolved once. + const [commandList] = useState(() => + commands().map((command) => ({ + command, + title: commandTitle(command.id, platform), + accelerator: command.accelerator ?? null + })) + ) + + const sections = useMemo( + () => + buildSections( + { + commands: commandList, + refs: loaded.refs, + currentBranch, + changes, + fileMatches: loaded.fileMatches, + filesLoading: loaded.filesLoading, + stashes, + worktrees: loaded.worktrees, + repos: loaded.repos, + currentRepoPath: repoPath, + commit: loaded.commit + }, + query, + expanded + ), + // The loaded fields one by one: `loaded` itself is a fresh object every + // render, and re-ranking 90k changed files on each hover would stutter. + [ + commandList, + loaded.refs, + loaded.fileMatches, + loaded.filesLoading, + loaded.worktrees, + loaded.repos, + loaded.commit, + currentBranch, + changes, + stashes, + repoPath, + query, + expanded + ] + ) + + const entries = useMemo( + () => + sections.flatMap((section): Entry[] => [ + ...section.hits.map((hit): Entry => ({ kind: 'hit', hit })), + ...(section.more > 0 ? [{ kind: 'more' as const, section: section.id }] : []) + ]), + [sections] + ) + const selected = Math.min(index, Math.max(0, entries.length - 1)) + + // A new query starts over: top result highlighted, sections collapsed. + // biome-ignore lint/correctness/useExhaustiveDependencies: the query is the trigger. + useEffect(() => { + setIndex(0) + setExpanded(new Set()) + }, [query]) + + const scrollTo = (entry: number) => + listRef.current?.querySelector(`[data-entry="${entry}"]`)?.scrollIntoView({ block: 'nearest' }) + + const branchContext: BranchMenuContext = { + current: currentBranch, + remote: (loaded.refs ?? []).filter((r) => r.kind === 'remote').map((r) => r.name), + githubWebUrl, + prByBranch + } + + const activate = (entry: Entry | undefined) => { + if (!entry) return + if (entry.kind === 'more') { + setExpanded((prev) => new Set(prev).add(entry.section)) + return + } + onClose() + activateItem(entry.hit.item, actions) + } + + const openMenu = (hit: PaletteHit, at: { x: number; y: number } | DOMRect) => { + const items = itemMenu(hit.item, actions, branchContext) + if (!items) return + const place = at instanceof DOMRect ? { x: at.left, y: at.bottom, anchor: at } : at + setMenu({ key: hit.key, items: closingFirst(items, onClose), ...place }) + } + + const closeMenu = () => { + setMenu(null) + inputRef.current?.focus() + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onClose() + return + } + if (e.key === 'Enter') { + e.preventDefault() + activate(entries[selected]) + return + } + if (e.key === 'Tab') { + // Tab never leaves the palette; on a row with actions it opens them, + // hung from the row's ⋯ like a click would. + e.preventDefault() + const entry = entries[selected] + if (entry?.kind !== 'hit') return + const row = listRef.current?.querySelector(`[data-entry="${selected}"]`) + const anchor = row?.querySelector('.palette-row__more') ?? row + if (anchor) openMenu(entry.hit, anchor.getBoundingClientRect()) + return + } + // Home/End stay with the input's caret; everything else navigates. + if (e.key === 'Home' || e.key === 'End' || entries.length === 0) return + const target = navTarget(e.key, selected, entries.length, PAGE) + if (target === null) return + e.preventDefault() + setIndex(target) + scrollTo(target) + } + + const empty = sections.length === 0 + + let entry = 0 + return ( + <> +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
+
+ + setQuery(e.target.value)} + onKeyDown={onKeyDown} + role="combobox" + aria-expanded + aria-controls="palette-results" + /> + {query !== '' && setQuery('')} />} +
+
+ {empty ? ( +
+ {query.trim() ? 'Nothing matches' : 'Nothing to show yet'} +
+ ) : ( + sections.map((section) => ( +
+
+ {section.label} + {section.loading && } +
+ {section.hits.map((hit) => { + const at = entry++ + return ( + at !== index && setIndex(at)} + onActivate={() => activate({ kind: 'hit', hit })} + onMenu={(pos) => openMenu(hit, pos)} + /> + ) + })} + {section.more > 0 && + (() => { + const at = entry++ + return ( + + ) + })()} +
+ )) + )} +
+
+ + ↑ + ↓ move + + + ↵ open + + + ⇥ actions + + + > commands @ branches & + tags / files + +
+
+
+ {menu && ( + + )} + + ) +} diff --git a/src/renderer/src/components/palette/paletteActions.tsx b/src/renderer/src/components/palette/paletteActions.tsx new file mode 100644 index 0000000..f09fd01 --- /dev/null +++ b/src/renderer/src/components/palette/paletteActions.tsx @@ -0,0 +1,235 @@ +// What the command palette does with a result: Enter reveals it where it +// lives (a branch in the Graph, a file in Changes or its history, a commit in +// History, …); the row's menu offers everything else — the same menu the +// object has in its home view, so acting on it never needs the reveal first. + +import type { AppCommandId } from '@shared/commands' +import { commandTitle } from '@shared/commands' +import type { ChangedFile, Commit, StashEntry } from '@shared/types' +import type { Modal } from '@/components/app/AppModals' +import { + type BranchAction, + type BranchMenuContext, + localBranchMenuItems, + remoteBranchMenuItems +} from '@/components/common/branchMenuItems' +import type { ContextMenuItem } from '@/components/common/ContextMenu' +import { copyPathItems } from '@/components/common/copyPathItems' +import { type FileHistoryMode, fileHistoryItems } from '@/components/common/fileHistoryItems' +import type { GraphRevealTarget } from '@/components/graph/reveal' +import { Icon } from '@/lib/icons' +import { platform } from '@/lib/platform' +import type { PaletteItem } from './paletteResults' + +/** What the palette asks App (and itself) to do. */ +export interface PaletteActions { + repoPath: string | null + runCommand: (id: AppCommandId) => void + revealInGraph: (target: GraphRevealTarget) => void + revealCommit: (commit: Commit) => void + /** A file result: its pending change in Changes, else its history. */ + openFile: (path: string, change: ChangedFile | null) => void + openFileHistory: (path: string, mode: FileHistoryMode) => void + openRepo: (path: string) => void + checkout: (name: string) => void + branchAction: (action: BranchAction, name: string) => void + openModal: (modal: Modal) => void + /** The History/Graph commit menu, for a commit result. */ + commitMenuFor: (commit: Commit) => ContextMenuItem[] + /** Run a mutating op (serialized, refreshed, errors → toast). */ + runOp: (fn: () => Promise) => Promise + reviewStash: (stash: StashEntry) => void +} + +/** Enter on a result: reveal it (or, for a command, run it). */ +export function activateItem(item: PaletteItem, actions: PaletteActions): void { + switch (item.kind) { + case 'command': + actions.runCommand(item.command.id) + break + case 'branch': + actions.revealInGraph({ kind: 'branch', name: item.ref.name, hash: item.ref.hash }) + break + case 'tag': + actions.revealInGraph({ kind: 'commit', hash: item.ref.hash }) + break + case 'commit': + actions.revealCommit(item.commit) + break + case 'file': + actions.openFile(item.path, item.change) + break + case 'stash': + actions.reviewStash(item.stash) + break + case 'place': + actions.openRepo(item.path) + break + } +} + +const revealInGraphItem = (onClick: () => void): ContextMenuItem => ({ + label: 'Show in Graph', + icon: , + onClick +}) + +/** The row's menu, or null for results that have nothing beyond Enter. */ +export function itemMenu( + item: PaletteItem, + actions: PaletteActions, + branches: BranchMenuContext +): ContextMenuItem[] | null { + const gg = window.gitgrove + switch (item.kind) { + case 'command': + return null + case 'branch': { + const { name, hash } = item.ref + const reveal = revealInGraphItem(() => actions.revealInGraph({ kind: 'branch', name, hash })) + const rest = + item.ref.kind === 'local' + ? localBranchMenuItems(name, branches, actions) + : remoteBranchMenuItems(name, branches, actions) + return [reveal, {}, ...rest] + } + case 'tag': { + const { name, hash } = item.ref + return [ + revealInGraphItem(() => actions.revealInGraph({ kind: 'commit', hash })), + {}, + { + label: 'Checkout Tag…', + icon: , + onClick: () => actions.openModal({ kind: 'checkout-commit', hash, shortHash: name }) + }, + { + label: 'Create Branch Here…', + icon: , + onClick: () => actions.openModal({ kind: 'new-branch', from: hash, fromLabel: name }) + }, + {}, + { + label: 'Copy Tag Name', + icon: , + onClick: () => gg.clipboardWrite(name) + }, + {}, + { + label: 'Delete Tag…', + icon: , + danger: true, + onClick: () => actions.openModal({ kind: 'delete-tag', name }) + } + ] + } + case 'commit': + return [ + { + label: 'Show in History', + icon: , + onClick: () => actions.revealCommit(item.commit) + }, + {}, + ...actions.commitMenuFor(item.commit) + ] + case 'file': { + const { path, change } = item + if (!actions.repoPath) return null + // The shared builders read only the path of a file they're handed. + const file: ChangedFile = change ?? { path, status: 'modified', staged: false } + const repoPath = actions.repoPath + return [ + ...(change + ? [ + { + label: 'Show in Changes', + icon: , + onClick: () => actions.openFile(path, change) + }, + {} + ] + : []), + ...fileHistoryItems(file, null, (p, mode) => actions.openFileHistory(p, mode)), + {}, + { + label: 'Open File', + icon: , + disabled: change?.status === 'deleted', + onClick: () => gg.openFileInEditor(repoPath, path) + }, + ...copyPathItems([file], repoPath) + ] + } + case 'stash': { + const { stash } = item + const repoPath = actions.repoPath + if (!repoPath) return null + const apply = (pop: boolean) => () => + actions.runOp(() => gg.stashApply(repoPath, stash.index, pop)) + return [ + { + label: 'Review…', + icon: , + onClick: () => actions.reviewStash(stash) + }, + {}, + // Auto-stashes only Restore (apply + clear) — see StashPanel. + ...(stash.auto + ? [{ label: 'Restore', icon: , onClick: apply(true) }] + : [ + { label: 'Apply', icon: , onClick: apply(false) }, + { label: 'Apply and Delete', icon: , onClick: apply(true) } + ]), + {}, + { + label: 'Delete Stash', + icon: , + danger: true, + onClick: () => actions.runOp(() => gg.stashDrop(repoPath, stash.index)) + } + ] + } + case 'place': + return [ + { + label: 'Open', + icon: , + onClick: () => actions.openRepo(item.path) + }, + { + label: 'Open in New Window', + icon: , + onClick: () => gg.openRepoInNewWindow(item.path) + }, + {}, + { + label: commandTitle('reveal-repo', platform), + icon: , + onClick: () => gg.revealRepo(item.path) + }, + { + label: 'Copy Path', + icon: , + onClick: () => gg.clipboardWrite(item.path) + } + ] + } +} + +/** + * The menu with every entry closing the palette first: an entry may open a + * dialog or move the view, and neither should happen underneath it. + */ +export function closingFirst(items: ContextMenuItem[], close: () => void): ContextMenuItem[] { + return items.map((item) => ({ + ...item, + onClick: item.onClick + ? () => { + close() + item.onClick?.() + } + : undefined, + submenu: item.submenu ? closingFirst(item.submenu, close) : undefined + })) +} diff --git a/src/renderer/src/components/palette/paletteResults.test.ts b/src/renderer/src/components/palette/paletteResults.test.ts new file mode 100644 index 0000000..2b3f69c --- /dev/null +++ b/src/renderer/src/components/palette/paletteResults.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'bun:test' +import { appCommand } from '@shared/commands' +import type { ChangedFile, Commit, RefEntry, StashEntry } from '@shared/types' +import { + buildSections, + COLLAPSED_ROWS, + looksLikeCommitId, + type PaletteSources, + parseScope, + type SectionId +} from './paletteResults' + +const ref = (kind: RefEntry['kind'], name: string, date = 0): RefEntry => ({ + kind, + name, + hash: 'a'.repeat(40), + date +}) + +const change = (path: string): ChangedFile => ({ path, status: 'modified', staged: false }) + +const commit: Commit = { + hash: 'abcdef1234567890abcdef1234567890abcdef12', + shortHash: 'abcdef1', + subject: 'Fix it', + body: '', + authorName: 'A', + authorEmail: 'a@example.com', + date: '2026-01-01T00:00:00Z', + relativeDate: 'today', + refs: '', + parents: [] +} + +const stash: StashEntry = { + index: 0, + sha: 'f'.repeat(40), + message: 'wip on login form', + branchName: 'main', + auto: false, + relativeDate: 'today' +} as StashEntry + +function sources(overrides: Partial = {}): PaletteSources { + return { + commands: [ + { command: appCommand('fetch'), title: 'Fetch', accelerator: null }, + { command: appCommand('new-branch'), title: 'New Branch…', accelerator: null } + ], + refs: [ + ref('local', 'feature/login', 3), + ref('local', 'main', 2), + ref('remote', 'origin/main', 2), + ref('tag', 'v1.0.0', 1) + ], + currentBranch: 'main', + changes: [change('src/login.ts')], + fileMatches: [], + filesLoading: false, + stashes: [stash], + worktrees: [], + repos: [], + currentRepoPath: '/repo', + commit: null, + ...overrides + } +} + +const none = new Set() +const ids = (s: PaletteSources, q: string) => buildSections(s, q, none).map((x) => x.id) +const keys = (s: PaletteSources, q: string, id: SectionId) => + buildSections(s, q, none) + .find((x) => x.id === id) + ?.hits.map((h) => h.key) ?? [] + +describe('parseScope', () => { + it('narrows by a leading prefix', () => { + expect(parseScope('>fetch')).toEqual({ scope: 'commands', text: 'fetch' }) + expect(parseScope(' @main')).toEqual({ scope: 'refs', text: 'main' }) + expect(parseScope('/app')).toEqual({ scope: 'files', text: 'app' }) + expect(parseScope('main')).toEqual({ scope: 'all', text: 'main' }) + }) +}) + +describe('looksLikeCommitId', () => { + it('accepts 7 to 40 hex digits only', () => { + expect(looksLikeCommitId('abcdef1')).toBe(true) + expect(looksLikeCommitId('abcdef')).toBe(false) + expect(looksLikeCommitId('abcdefg')).toBe(false) + }) +}) + +describe('buildSections', () => { + it('orders sections the same way every time and drops empty ones', () => { + expect(ids(sources(), 'main')).toEqual(['branches']) + expect(ids(sources(), 'login')).toEqual(['branches', 'files', 'stashes']) + }) + + it('lists local branches with the current one first when the query is empty', () => { + expect(keys(sources(), '', 'branches')).toEqual([ + 'branch:local:main', + 'branch:local:feature/login' + ]) + }) + + it('keeps tags out of the unfiltered view and finds them once searched', () => { + expect(ids(sources(), '')).not.toContain('tags') + expect(keys(sources(), 'v1', 'tags')).toEqual(['tag:tag:v1.0.0']) + }) + + it('scopes to one kind of thing with a prefix', () => { + expect(ids(sources(), '>fetch')).toEqual(['commands']) + expect(ids(sources(), '@main')).toEqual(['branches']) + expect(ids(sources(), '/login')).toEqual(['files']) + }) + + it('merges changed files with main matches, each path once', () => { + const s = sources({ + fileMatches: [ + { path: 'src/login.ts', score: 1, positions: [] }, + { path: 'docs/login.md', score: 1, positions: [] } + ] + }) + const files = buildSections(s, 'login', none).find((x) => x.id === 'files')! + expect(files.hits.map((h) => h.key).sort()).toEqual(['file:docs/login.md', 'file:src/login.ts']) + const changed = files.hits.find((h) => h.key === 'file:src/login.ts')! + expect(changed.item.kind === 'file' && changed.item.change?.status).toBe('modified') + }) + + it('shows a loading section while files are being searched', () => { + const files = buildSections(sources({ filesLoading: true, changes: [] }), 'zzz', none) + expect(files.map((x) => [x.id, x.loading])).toEqual([['files', true]]) + }) + + it('caps a section until it is expanded', () => { + const many = Array.from({ length: 12 }, (_, i) => ref('local', `topic-${i}`)) + const s = sources({ refs: many }) + const collapsed = buildSections(s, 'topic', none).find((x) => x.id === 'branches')! + expect(collapsed.hits).toHaveLength(COLLAPSED_ROWS) + expect(collapsed.more).toBe(12 - COLLAPSED_ROWS) + const open = buildSections(s, 'topic', new Set(['branches'])).find((x) => x.id === 'branches')! + expect(open.hits).toHaveLength(12) + expect(open.more).toBe(0) + }) + + it('offers a pasted commit id first', () => { + const s = sources({ commit }) + expect(ids(s, 'ABCDEF12')[0]).toBe('commit') + const hit = buildSections(s, 'abcdef12', none)[0].hits[0] + expect(hit.highlights).toEqual([[0, 1, 2, 3, 4, 5, 6]]) + // A stale lookup for another id never shows. + expect(ids(s, '1234567')).not.toContain('commit') + }) + + it('highlights the matched characters of each field', () => { + const hit = buildSections(sources(), 'fetch', none)[0].hits[0] + expect(hit.highlights).toEqual([[0, 1, 2, 3, 4]]) + }) + + it('never offers the open repo or the current worktree as a place to go', () => { + const s = sources({ + repos: [ + { path: '/repo', name: 'repo', lastOpened: 1, missing: false }, + { path: '/other', name: 'other', lastOpened: 1, missing: false }, + { path: '/gone', name: 'gone', lastOpened: 1, missing: true } + ], + worktrees: [ + { path: '/repo', branch: 'main', headShort: 'a', isMain: true, isCurrent: true }, + { path: '/wt', branch: 'hotfix', headShort: 'b', isMain: false, isCurrent: false } + ] + }) + expect(keys(s, '', 'repos')).toEqual(['repo:/other']) + expect(keys(s, '', 'worktrees')).toEqual(['worktree:/wt']) + }) +}) diff --git a/src/renderer/src/components/palette/paletteResults.ts b/src/renderer/src/components/palette/paletteResults.ts new file mode 100644 index 0000000..24d1303 --- /dev/null +++ b/src/renderer/src/components/palette/paletteResults.ts @@ -0,0 +1,329 @@ +// The command palette's result model: from everything it can search (commands, +// refs, files, stashes, worktrees, recent repos, a pasted commit id) and the +// typed query, the ranked sections it shows. Pure, so ranking, scoping and +// section limits are unit-tested without rendering anything. +// +// Every source is fuzzy-ranked with shared/fuzzy; files arrive pre-ranked +// from main (the full path list never crosses IPC) and are merged here with +// the working tree's changed files, which main's tracked-file index can't see +// when they're untracked. + +import type { AppCommand } from '@shared/commands' +import { foldCase, fuzzyMatch, fuzzyMatchPath, normalizeQuery, TopMatches } from '@shared/fuzzy' +import type { + ChangedFile, + Commit, + FileSearchMatch, + RecentRepo, + RefEntry, + StashEntry, + WorktreeInfo +} from '@shared/types' +import { stashLabel } from '@/lib/format' + +export type PaletteItem = + | { kind: 'command'; command: AppCommand; title: string; accelerator: string | null } + | { kind: 'branch'; ref: RefEntry; current: boolean } + | { kind: 'tag'; ref: RefEntry } + /** A tracked or changed file; `change` is set when it has pending changes. */ + | { kind: 'file'; path: string; change: ChangedFile | null } + | { kind: 'commit'; commit: Commit } + | { kind: 'stash'; stash: StashEntry; label: string } + /** Somewhere to open: a linked worktree or a recently opened repository. */ + | { kind: 'place'; place: 'worktree' | 'repo'; name: string; path: string } + +export interface PaletteHit { + key: string + item: PaletteItem + /** Matched character indexes per displayed field (see fieldsOf), for highlighting. */ + highlights: number[][] +} + +export type SectionId = + | 'commit' + | 'commands' + | 'branches' + | 'files' + | 'tags' + | 'stashes' + | 'worktrees' + | 'repos' + +export interface PaletteSection { + id: SectionId + label: string + hits: PaletteHit[] + /** Ranked hits held back until the section is expanded ("N more"). */ + more: number + /** The source is still loading — the header shows a quiet spinner. */ + loading: boolean +} + +/** Everything the palette searches. `null` = that source is still loading. */ +export interface PaletteSources { + commands: { command: AppCommand; title: string; accelerator: string | null }[] + refs: RefEntry[] | null + currentBranch: string | null + changes: ChangedFile[] + /** Main's ranked tracked-file matches for the latest answered query. */ + fileMatches: FileSearchMatch[] + /** A file search for the current query is still running. */ + filesLoading: boolean + stashes: StashEntry[] + worktrees: WorktreeInfo[] | null + repos: RecentRepo[] | null + currentRepoPath: string | null + /** The commit a pasted id resolved to, if any. */ + commit: Commit | null +} + +/** A leading character narrows the search to one kind of thing. */ +export type Scope = 'all' | 'commands' | 'refs' | 'files' + +const SCOPE_PREFIXES: Record = { '>': 'commands', '@': 'refs', '/': 'files' } + +export function parseScope(query: string): { scope: Scope; text: string } { + const trimmed = query.trimStart() + const scope = SCOPE_PREFIXES[trimmed[0]] + return scope ? { scope, text: trimmed.slice(1) } : { scope: 'all', text: query } +} + +/** What a pasted commit id looks like: 7+ hex digits (git's default abbrev). */ +export function looksLikeCommitId(text: string): boolean { + return /^[0-9a-f]{7,40}$/i.test(text.trim()) +} + +/** Rows a section shows before "N more". */ +export const COLLAPSED_ROWS = 5 +/** Rows an expanded section shows (and what file search asks main for). */ +export const EXPANDED_ROWS = 50 + +const SECTION_ORDER: { id: SectionId; label: string; scopes: Scope[] }[] = [ + { id: 'commit', label: 'Commit', scopes: ['all'] }, + { id: 'commands', label: 'Commands', scopes: ['all', 'commands'] }, + { id: 'branches', label: 'Branches', scopes: ['all', 'refs'] }, + { id: 'files', label: 'Files', scopes: ['all', 'files'] }, + { id: 'tags', label: 'Tags', scopes: ['all', 'refs'] }, + { id: 'stashes', label: 'Stashes', scopes: ['all'] }, + { id: 'worktrees', label: 'Worktrees', scopes: ['all'] }, + { id: 'repos', label: 'Repositories', scopes: ['all'] } +] + +/** One searchable, displayed field of an item. */ +interface Field { + text: string + /** Match as a path (file-name first) rather than free text. */ + path?: boolean +} + +/** The fields each item kind is searched — and highlighted — by. */ +export function fieldsOf(item: PaletteItem): Field[] { + switch (item.kind) { + case 'command': + return [{ text: item.title }] + case 'branch': + case 'tag': + return [{ text: item.ref.name }] + case 'file': + return [{ text: item.path, path: true }] + case 'commit': + return [{ text: item.commit.shortHash }] + case 'stash': + return [{ text: item.label }] + case 'place': + return [{ text: item.name }, { text: item.path, path: true }] + } +} + +function hitKey(item: PaletteItem): string { + switch (item.kind) { + case 'command': + return `command:${item.command.id}` + case 'branch': + case 'tag': + return `${item.kind}:${item.ref.kind}:${item.ref.name}` + case 'file': + return `file:${item.path}` + case 'commit': + return `commit:${item.commit.hash}` + case 'stash': + return `stash:${item.stash.sha}` + case 'place': + return `${item.place}:${item.path}` + } +} + +const unmatched = (item: PaletteItem): PaletteHit => ({ + key: hitKey(item), + item, + highlights: fieldsOf(item).map(() => []) +}) + +/** Score an item on its best field; every field that matched gets highlighted. */ +function matchItem(needle: string, item: PaletteItem): { hit: PaletteHit; score: number } | null { + let best: number | null = null + const highlights = fieldsOf(item).map((field) => { + const folded = foldCase(field.text) + const match = field.path + ? fuzzyMatchPath(needle, field.text, folded) + : fuzzyMatch(needle, field.text, folded) + if (!match) return [] + if (best === null || match.score > best) best = match.score + return match.positions + }) + return best === null ? null : { hit: { key: hitKey(item), item, highlights }, score: best } +} + +/** The best EXPANDED_ROWS items for `needle`, best first — or all of them, in + * source order, for an empty query. */ +function rank(needle: string, items: PaletteItem[]): PaletteHit[] { + if (needle.length === 0) return items.slice(0, EXPANDED_ROWS).map(unmatched) + const top = new TopMatches(EXPANDED_ROWS) + for (const item of items) { + const match = matchItem(needle, item) + if (match) top.add(match.hit, match.score, fieldsOf(item)[0].text.length) + } + return top.items() +} + +/** + * Files: the working tree's changes ranked here, merged with main's ranked + * tracked files (whose scores come from the same matcher, so they compare). + * A changed file appears once, carrying its change. An empty query lists the + * changes alone — "what am I working on". + */ +function rankFiles(needle: string, sources: PaletteSources): PaletteHit[] { + const changes = sources.changes.map( + (change): PaletteItem => ({ kind: 'file', path: change.path, change }) + ) + if (needle.length === 0) return rank(needle, changes) + const top = new TopMatches(EXPANDED_ROWS) + const changed = new Set() + for (const item of changes) { + const match = matchItem(needle, item) + if (item.kind === 'file') changed.add(item.path) + if (match) top.add(match.hit, match.score, fieldsOf(item)[0].text.length) + } + for (const file of sources.fileMatches) { + if (changed.has(file.path)) continue + const item: PaletteItem = { kind: 'file', path: file.path, change: null } + top.add({ key: hitKey(item), item, highlights: [file.positions] }, file.score, file.path.length) + } + return top.items() +} + +function sectionHits(id: SectionId, needle: string, sources: PaletteSources): PaletteHit[] { + const refs = sources.refs ?? [] + switch (id) { + case 'commit': { + const commit = sources.commit + if (!commit || !looksLikeCommitId(needle) || !commit.hash.startsWith(needle)) return [] + const shown = Math.min(needle.length, commit.shortHash.length) + const item: PaletteItem = { kind: 'commit', commit } + return [{ key: hitKey(item), item, highlights: [[...Array(shown).keys()]] }] + } + case 'commands': + return rank( + needle, + sources.commands.map((c): PaletteItem => ({ kind: 'command', ...c })) + ) + case 'branches': { + // Unfiltered, the list is "where you've been working": local branches, + // the checked-out one first, then by recency (refs arrive newest first). + const branches = refs.filter( + (r) => r.kind !== 'tag' && (needle.length > 0 || r.kind === 'local') + ) + const items = branches.map( + (ref): PaletteItem => ({ + kind: 'branch', + ref, + current: ref.kind === 'local' && ref.name === sources.currentBranch + }) + ) + if (needle.length === 0) items.sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a))) + return rank(needle, items) + } + case 'files': + return rankFiles(needle, sources) + case 'tags': + // Tags pile up by the thousand; they only show once you search. + if (needle.length === 0) return [] + return rank( + needle, + refs.filter((r) => r.kind === 'tag').map((ref): PaletteItem => ({ kind: 'tag', ref })) + ) + case 'stashes': + return rank( + needle, + sources.stashes.map( + (stash): PaletteItem => ({ kind: 'stash', stash, label: stashLabel(stash) }) + ) + ) + case 'worktrees': + return rank( + needle, + (sources.worktrees ?? []) + .filter((w) => !w.isCurrent) + .map( + (w): PaletteItem => ({ + kind: 'place', + place: 'worktree', + name: w.branch ?? `detached @ ${w.headShort}`, + path: w.path + }) + ) + ) + case 'repos': + return rank( + needle, + (sources.repos ?? []) + // A vanished folder can't be opened from here (the switcher's + // recovery flow handles it). + .filter((r) => r.path !== sources.currentRepoPath && !r.missing) + .map((r): PaletteItem => ({ kind: 'place', place: 'repo', name: r.name, path: r.path })) + ) + } +} + +const isCurrent = (item: PaletteItem) => item.kind === 'branch' && item.current + +function sectionLoading(id: SectionId, needle: string, sources: PaletteSources): boolean { + switch (id) { + case 'branches': + case 'tags': + return sources.refs === null + case 'files': + return needle.length > 0 && sources.filesLoading + case 'worktrees': + return sources.worktrees === null + case 'repos': + return sources.repos === null + default: + return false + } +} + +/** + * The sections to show for `query`, in a fixed order (so each kind of thing + * is always in the same place), each ranked, capped at COLLAPSED_ROWS unless + * `expanded`. Empty sections drop out — unless still loading, so the spinner + * says more may be coming. + */ +export function buildSections( + sources: PaletteSources, + query: string, + expanded: ReadonlySet +): PaletteSection[] { + const { scope, text } = parseScope(query) + const needle = normalizeQuery(text) + const sections: PaletteSection[] = [] + for (const { id, label, scopes } of SECTION_ORDER) { + if (!scopes.includes(scope)) continue + const all = sectionHits(id, needle, sources) + const loading = sectionLoading(id, needle, sources) + if (all.length === 0 && !loading) continue + const shown = expanded.has(id) ? all : all.slice(0, COLLAPSED_ROWS) + sections.push({ id, label, hits: shown, more: all.length - shown.length, loading }) + } + return sections +} diff --git a/src/renderer/src/components/palette/usePaletteSources.ts b/src/renderer/src/components/palette/usePaletteSources.ts new file mode 100644 index 0000000..feb4a00 --- /dev/null +++ b/src/renderer/src/components/palette/usePaletteSources.ts @@ -0,0 +1,98 @@ +// The command palette's asynchronous sources: what it loads when it opens +// (every ref, the worktrees, the recent repos) and what it asks for as the +// user types (main's fuzzy file search, a pasted commit id's commit). Each +// source fills in on its own — the palette never waits for the slowest one. +// +// Stale answers are dropped by request identity: a load number for the +// on-open loads (a repo switch reloads), and a per-keystroke counter for the +// typed lookups, so a slow reply for an old query can never overwrite a newer +// one. + +import { normalizeQuery } from '@shared/fuzzy' +import type { Commit, FileSearchMatch, RecentRepo, RefEntry, WorktreeInfo } from '@shared/types' +import { useEffect, useRef, useState } from 'react' +import { EXPANDED_ROWS, looksLikeCommitId, parseScope } from './paletteResults' + +/** Typing pause before asking main — short enough to feel live. */ +const LOOKUP_DEBOUNCE_MS = 40 + +export interface LoadedSources { + refs: RefEntry[] | null + worktrees: WorktreeInfo[] | null + repos: RecentRepo[] | null + fileMatches: FileSearchMatch[] + filesLoading: boolean + commit: Commit | null +} + +/** Mounted with the open palette: loads on mount, looks up as `query` changes. */ +export function usePaletteSources(repoPath: string | null, query: string): LoadedSources { + const [refs, setRefs] = useState(null) + const [worktrees, setWorktrees] = useState(null) + const [repos, setRepos] = useState(null) + const [fileMatches, setFileMatches] = useState([]) + const [filesLoading, setFilesLoading] = useState(false) + const [commit, setCommit] = useState(null) + const load = useRef(0) + const lookup = useRef(0) + + // On open: everything that doesn't depend on the query, loaded fresh (refs + // move on between opens) and in parallel. + useEffect(() => { + const id = ++load.current + const current = () => load.current === id + const gg = window.gitgrove + setRefs(repoPath ? null : []) + setWorktrees(repoPath ? null : []) + setRepos(null) + setFileMatches([]) + setCommit(null) + gg.recentRepos() + .then((list) => current() && setRepos(list)) + .catch(() => current() && setRepos([])) + if (!repoPath) return + gg.refs(repoPath) + .then((list) => current() && setRefs(list)) + .catch(() => current() && setRefs([])) + gg.worktreeList(repoPath) + .then((list) => current() && setWorktrees(list)) + .catch(() => current() && setWorktrees([])) + }, [repoPath]) + + // As the user types: the file search and the commit-id lookup, debounced. + const { scope, text } = parseScope(query) + const fileQuery = repoPath && (scope === 'all' || scope === 'files') ? text : '' + const commitQuery = repoPath && scope === 'all' && looksLikeCommitId(text) ? text : '' + useEffect(() => { + const id = ++lookup.current + const current = () => lookup.current === id + const needFiles = normalizeQuery(fileQuery).length > 0 + setFilesLoading(needFiles) + if (!repoPath || (!needFiles && !commitQuery)) { + setFileMatches([]) + return + } + const gg = window.gitgrove + const timer = setTimeout(() => { + if (needFiles) { + gg.searchFiles(repoPath, fileQuery, EXPANDED_ROWS) + .then((result) => { + // null: main dropped it for a newer query of ours — that one answers. + if (!current() || !result) return + setFileMatches(result.matches) + setFilesLoading(false) + }) + .catch(() => current() && setFilesLoading(false)) + } + if (commitQuery) { + gg.log(repoPath, { ref: commitQuery.trim(), limit: 1 }) + .then((commits) => current() && setCommit(commits[0] ?? null)) + // Not a commit (or ambiguous): nothing to offer. + .catch(() => current() && setCommit(null)) + } + }, LOOKUP_DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [repoPath, fileQuery, commitQuery]) + + return { refs, worktrees, repos, fileMatches, filesLoading, commit } +} diff --git a/src/renderer/src/components/toolbar/Toolbar.tsx b/src/renderer/src/components/toolbar/Toolbar.tsx index d5a0782..21b0314 100644 --- a/src/renderer/src/components/toolbar/Toolbar.tsx +++ b/src/renderer/src/components/toolbar/Toolbar.tsx @@ -1,6 +1,7 @@ import type { BranchInfo, RepoSummary, SyncStatus } from '@shared/types' import { useState } from 'react' import type { BranchAction } from '@/components/common/branchMenuItems' +import { Shortcut } from '@/components/common/Shortcut' import { Icon } from '@/lib/icons' import { isMac } from '@/lib/platform' import type { BranchPrs } from '@/lib/pr-order' @@ -12,6 +13,8 @@ import { type SyncAction, SyncButton } from './SyncButton' import { ThemeSwitcher } from './ThemeSwitcher' import { WindowControls } from './WindowControls' +// styles: styles/features/toolbar.css + interface Props { repo: RepoSummary | null branch: BranchInfo | null @@ -45,6 +48,8 @@ interface Props { onRefresh: () => void onThemeChange: (pref: ThemePref) => void onAbout: () => void + /** Open the command palette (also Cmd/Ctrl+K, from the menu). */ + onSearch: () => void } // Persist whether the (Windows/Linux) menu bar is expanded, mirroring how the @@ -82,7 +87,8 @@ export function Toolbar({ onCheckout, onRefresh, onThemeChange, - onAbout + onAbout, + onSearch }: Props) { const [menuExpanded, setMenuExpanded] = useState(readMenuExpanded) @@ -147,6 +153,16 @@ export function Toolbar({ /> )}
+ {repo && ( {repo && ( diff --git a/src/renderer/src/styles/features/toolbar.css b/src/renderer/src/styles/features/toolbar.css index 6f4f266..44ffcd7 100644 --- a/src/renderer/src/styles/features/toolbar.css +++ b/src/renderer/src/styles/features/toolbar.css @@ -336,7 +336,7 @@ align-items: center; gap: 7px; height: 34px; - min-width: 180px; + min-width: 240px; padding: 0 7px 0 10px; border-radius: var(--radius-sm); border: 1px solid var(--border);