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..f1d6154 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' @@ -43,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' @@ -50,8 +52,8 @@ 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 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' @@ -61,6 +63,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 +74,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 @@ -543,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 () => { @@ -1227,23 +1273,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 +1307,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.) @@ -1420,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 + }} + /> ) @@ -1445,6 +1524,7 @@ export function App() { onRefresh={refresh} onThemeChange={setThemePref} onAbout={() => setAboutOpen(true)} + onSearch={() => setSearchOpen(true)} />
{git === null ? ( @@ -1505,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 } @@ -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/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/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..6c1257e 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' @@ -26,6 +26,7 @@ import { import { linkableChains, twinHashes } from './links' import { relatedBranches } from './related' import { releaseLineVersion, releaseVersionWithOverride } from './releases' +import { type GraphRevealRequest, resolveReveal } from './reveal' import { isPrLookupRow, rowPullRequests } from './rowPrs' import { computeSearchHits } from './searchGlow' import { squashedBranchesByLanding } from './squash' @@ -70,6 +71,11 @@ interface Props { /** Each row's PRs keyed by branchKey, re-reported per layout — what the * detail pane lists for the open branch. */ onRowPrsChange: (byBranch: ReadonlyMap) => 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/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..fc77e5f 100644 --- a/src/renderer/src/components/toolbar/Toolbar.tsx +++ b/src/renderer/src/components/toolbar/Toolbar.tsx @@ -1,16 +1,20 @@ 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' 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' import { ThemeSwitcher } from './ThemeSwitcher' import { WindowControls } from './WindowControls' +// styles: styles/features/toolbar.css + interface Props { repo: RepoSummary | null branch: BranchInfo | null @@ -44,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 @@ -81,7 +87,8 @@ export function Toolbar({ onCheckout, onRefresh, onThemeChange, - onAbout + onAbout, + onSearch }: Props) { const [menuExpanded, setMenuExpanded] = useState(readMenuExpanded) @@ -146,6 +153,16 @@ export function Toolbar({ /> )}
+ {repo && (