diff --git a/.changeset/d18476e7c268bb77.md b/.changeset/d18476e7c268bb77.md new file mode 100644 index 0000000..0354017 --- /dev/null +++ b/.changeset/d18476e7c268bb77.md @@ -0,0 +1,5 @@ +--- +"@kitlangton/ghui": minor +--- + +Auto-detect GitHub remotes from `.git/config` and switch to repository view on startup. Redesigned "Open repository" modal with a selectable list of detected remotes, while preserving the ability to type a custom `owner/name` or GitHub URL. diff --git a/README.md b/README.md index dd27453..c9fd87a 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,14 @@ running. ## Keybindings +When launched inside a Git repository, ghui automatically detects GitHub remotes from `.git/config` and opens the repository view for the default remote (`origin`, then `upstream`, then first available). + - `up` / `down`: move selection - `k` / `j`: move selection - `gg` / `G`: jump to first or last pull request - `ctrl-u` / `ctrl-d`: page up or down - `tab` / `shift-tab`: switch PR queue +- `h`: go to home view (your authored pull requests) - `ctrl-p` / `cmd-k`: open the command palette - `/`: filter - `enter`: expand details; normal PR actions still work while details are expanded @@ -113,6 +116,7 @@ running. - `t`: choose a fixed theme, including `System` to match your terminal colors; press `m` in the theme picker to follow the OS light/dark appearance with separate theme choices - `l`: manage labels - `o`: open PR in browser +- `shift-o`: open repository picker (auto-populated with remotes from `.git/config`) - `y`: copy PR metadata - `q`: quit diff --git a/src/App.tsx b/src/App.tsx index c0319a0..6e96d29 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,6 +46,7 @@ import { viewMode, viewRepository, } from "./pullRequestViews.js" +import { detectGitHubRemotes } from "./gitRemotes.js" import { BrowserOpener } from "./services/BrowserOpener.js" import { CacheService, type PullRequestCacheKey } from "./services/CacheService.js" import { Clipboard } from "./services/Clipboard.js" @@ -271,6 +272,14 @@ const trimQueueLoadCache = (cache: Partial>) => const remove = new Set(repositoryKeys.slice(0, repositoryKeys.length - MAX_REPOSITORY_CACHE_ENTRIES)) return Object.fromEntries(Object.entries(cache).filter(([key]) => !remove.has(key))) as Partial> } +const isRateLimitError = (error: unknown): boolean => { + if (typeof error !== "object" || error === null) return false + if (!("_tag" in error) || (error as Record)._tag !== "CommandError") return false + const detail = (error as Record).detail + if (typeof detail !== "string") return false + const lower = detail.toLowerCase() + return lower.includes("rate limit") +} const pullRequestsAtom = githubRuntime .atom( GitHubService.use((github) => @@ -306,7 +315,11 @@ const pullRequestsAtom = githubRuntime }), ), ), - Effect.retry({ times: PR_FETCH_RETRIES, schedule: Schedule.exponential("300 millis", 2) }), + Effect.retry({ + times: PR_FETCH_RETRIES, + schedule: Schedule.exponential("300 millis", 2), + while: (error) => !isRateLimitError(error), + }), Effect.tapError(() => Atom.set(retryProgressAtom, initialRetryProgress)), ) @@ -841,11 +854,12 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const detailPrefetchTimeoutRef = useRef | null>(null) const detailHydrationRef = useRef(new Map()) const refreshGenerationRef = useRef(0) - const didMountQueueModeRef = useRef(false) const lastPullRequestRefreshAtRef = useRef(0) const terminalFocusedRef = useRef(true) const terminalWasBlurredRef = useRef(false) const pullRequestStatusRef = useRef("loading") + const detectedRemotesRef = useRef([]) + const refreshInFlightRef = useRef(false) const refreshPullRequestsRef = useRef<(message?: string, options?: { readonly resetTransientState?: boolean }) => void>(() => {}) const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {}) const detailScrollRef = useRef(null) @@ -1067,6 +1081,8 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { updatePullRequest(pullRequest.url, () => pullRequest) } const refreshPullRequests = (message?: string, options: { readonly resetTransientState?: boolean } = {}) => { + if (refreshInFlightRef.current) return + refreshInFlightRef.current = true refreshGenerationRef.current += 1 detailHydrationRef.current.clear() if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current) @@ -1244,13 +1260,10 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { }, [pullRequestLoad?.fetchedAt]) useEffect(() => { - if (!didMountQueueModeRef.current) { - didMountQueueModeRef.current = true - return + if (!pullRequestResult.waiting) { + refreshInFlightRef.current = false } - if (registry.get(queueLoadCacheAtom)[currentQueueCacheKey]) return - refreshPullRequestsAtom() - }, [currentQueueCacheKey, refreshPullRequestsAtom, registry]) + }, [pullRequestResult]) useEffect(() => { if (!refreshCompletionMessage || refreshStartedAt === null) return @@ -1274,6 +1287,16 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { void pruneCache().catch(() => {}) }, [pruneCache]) + useEffect(() => { + if (mockPrCount !== null) return + detectGitHubRemotes().then((remotes) => { + detectedRemotesRef.current = remotes + if (remotes.length > 0 && viewEquals(registry.get(activeViewAtom), initialPullRequestView())) { + switchViewTo({ _tag: "Repository", repository: remotes[0]! }) + } + }) + }, []) + useEffect(() => { const handleFocus = () => { terminalFocusedRef.current = true @@ -2720,11 +2743,27 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const openCommandPalette = () => { setCommandPalette(initialCommandPaletteState) } + const moveRepositorySelection = (delta: -1 | 1) => { + const normalized = openRepositoryModal.query.trim().toLowerCase() + const filtered = normalized.length === 0 ? detectedRemotesRef.current : detectedRemotesRef.current.filter((s) => s.toLowerCase().includes(normalized)) + if (filtered.length === 0) return + setOpenRepositoryModal((current) => { + const selectedIndex = wrapIndex(current.selectedIndex + delta, filtered.length) + return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex } + }) + } const openRepositoryPicker = () => { - setOpenRepositoryModal({ query: selectedRepository ?? "", error: null }) + setOpenRepositoryModal({ query: "", selectedIndex: 0, error: null }) } const openRepositoryFromInput = () => { - const repository = parseRepositoryInput(openRepositoryModal.query) + const normalized = openRepositoryModal.query.trim().toLowerCase() + const filtered = normalized.length === 0 ? detectedRemotesRef.current : detectedRemotesRef.current.filter((s) => s.toLowerCase().includes(normalized)) + let repository: string | null = null + if (filtered.length > 0 && openRepositoryModal.selectedIndex < filtered.length) { + repository = filtered[openRepositoryModal.selectedIndex]! + } else { + repository = parseRepositoryInput(openRepositoryModal.query) + } if (!repository) { setOpenRepositoryModal((current) => ({ ...current, error: "Enter a repository as owner/name or a GitHub URL." })) return @@ -2740,7 +2779,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { return true } if (openRepositoryModalActive) { - setOpenRepositoryModal((current) => ({ ...current, query: current.query + singleLineText(text), error: null })) + setOpenRepositoryModal((current) => ({ ...current, query: current.query + singleLineText(text), selectedIndex: 0, error: null })) return true } if (themeModalActive && themeModal.filterMode) { @@ -3134,6 +3173,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { openRepositoryModal: { closeModal: closeActiveModal, openFromInput: openRepositoryFromInput, + moveSelection: moveRepositorySelection, }, commentModal: { closeModal: closeActiveModal, @@ -3214,6 +3254,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { refresh: () => runCommandById("pull.refresh"), openInBrowser: () => runCommandById("pull.open-browser"), copyMetadata: () => runCommandById("pull.copy-metadata"), + openRepositoryPicker: () => runCommandById("repository.open"), }, commentsView: { halfPage, @@ -3273,11 +3314,10 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { if (openRepositoryModalActive) { if (isSingleLineInputKey(key)) { - setOpenRepositoryModal((current) => ({ - ...current, - query: editSingleLineInput(current.query, key) ?? current.query, - error: null, - })) + setOpenRepositoryModal((current) => { + const query = editSingleLineInput(current.query, key) ?? current.query + return current.query === query && current.selectedIndex === 0 && current.error === null ? current : { ...current, query, selectedIndex: 0, error: null } + }) } return } @@ -3459,7 +3499,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const themeModalHeight = themeLayout.height const themeModalLeft = themeLayout.left const themeModalTop = themeLayout.top - const openRepositoryLayout = sizedModal(46, 76, 8, 8) + const openRepositoryLayout = sizedModal(46, 76, 8, 14) const openRepositoryModalWidth = openRepositoryLayout.width const openRepositoryModalHeight = openRepositoryLayout.height const openRepositoryModalLeft = openRepositoryLayout.left @@ -3826,6 +3866,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { {openRepositoryModalActive ? ( { + const match = url.match(GITHUB_REMOTE_PATTERN) + if (!match) return null + const [, owner, repo] = match + if (!owner || !repo) return null + return `${owner}/${repo}` +} + +const readGitConfig = async (): Promise => { + try { + const file = Bun.file(".git/config") + if (!(await file.exists())) return "" + return await file.text() + } catch { + return "" + } +} + +const parseGitConfigRemotes = (content: string): Map => { + const remotes = new Map() + let currentRemote: string | null = null + for (const raw of content.split("\n")) { + const line = raw.trim() + const sectionMatch = line.match(/^\[remote\s+"([^"]+)"\s*\]$/) + if (sectionMatch) { + currentRemote = sectionMatch[1]! + continue + } + if (currentRemote && line.startsWith("[") && line.endsWith("]")) { + currentRemote = null + continue + } + const urlMatch = line.match(/^url\s*=\s*(.+)$/) + if (urlMatch && currentRemote && !remotes.has(currentRemote)) { + remotes.set(currentRemote, urlMatch[1]!.trim()) + } + } + return remotes +} + +export const detectGitHubRemotes = async (): Promise => { + const content = await readGitConfig() + if (!content) return [] + + const remotes = parseGitConfigRemotes(content) + const found = new Map() // repo -> remote name (first seen) + for (const [remoteName, remoteUrl] of remotes) { + const repo = parseGitRemoteUrl(remoteUrl) + if (!repo) continue + if (!found.has(repo)) found.set(repo, remoteName) + } + + const entries = [...found.entries()] + const priority = (name: string) => { + if (name === "origin") return 0 + if (name === "upstream") return 1 + return 2 + } + entries.sort(([, a], [, b]) => priority(a) - priority(b) || a.localeCompare(b)) + return entries.map(([repo]) => repo) +} diff --git a/src/keymap/detailView.ts b/src/keymap/detailView.ts index 8840375..aafb75a 100644 --- a/src/keymap/detailView.ts +++ b/src/keymap/detailView.ts @@ -13,6 +13,7 @@ export interface DetailViewCtx extends Scrollable { readonly refresh: () => void readonly openInBrowser: () => void readonly copyMetadata: () => void + readonly openRepositoryPicker: () => void } const Detail = context() @@ -31,4 +32,5 @@ export const detailViewKeymap = Detail( { id: "detail.refresh", title: "Refresh", keys: ["r"], run: (s) => s.refresh() }, { id: "detail.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.openInBrowser() }, { id: "detail.copy", title: "Copy metadata", keys: ["y"], run: (s) => s.copyMetadata() }, + { id: "detail.open-repo", title: "Open repository", keys: ["shift+o"], run: (s) => s.openRepositoryPicker() }, ) diff --git a/src/keymap/listNav.ts b/src/keymap/listNav.ts index 9307fb0..32de22b 100644 --- a/src/keymap/listNav.ts +++ b/src/keymap/listNav.ts @@ -35,6 +35,7 @@ export const listNavKeymap = List( { id: "list.merge", title: "Merge", keys: ["m", "shift+m"], run: (s) => s.runCommandById("pull.merge") }, { id: "list.close-pr", title: "Close PR", keys: ["x"], run: (s) => s.runCommandById("pull.close") }, { id: "list.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.runCommandById("pull.open-browser") }, + { id: "list.open-repo", title: "Open repository", keys: ["shift+o"], run: (s) => s.runCommandById("repository.open") }, { id: "list.toggle-draft", title: "Toggle draft", keys: ["s", "shift+s"], run: (s) => s.runCommandById("pull.toggle-draft") }, { id: "list.copy", title: "Copy metadata", keys: ["y"], run: (s) => s.runCommandById("pull.copy-metadata") }, { id: "list.detail.open", title: "Open details", keys: ["return"], run: (s) => s.runCommandById("detail.open") }, @@ -42,6 +43,7 @@ export const listNavKeymap = List( // Queue mode tabs { id: "list.next-tab", title: "Next view", keys: ["tab"], run: (s) => s.switchQueueMode(1) }, { id: "list.prev-tab", title: "Previous view", keys: ["shift+tab"], run: (s) => s.switchQueueMode(-1) }, + { id: "list.home-view", title: "Home view", keys: ["h"], run: (s) => s.runCommandById("view.authored") }, // Escape clears filter only when one is set { diff --git a/src/keymap/openRepositoryModal.ts b/src/keymap/openRepositoryModal.ts index 83229fd..cb91f47 100644 --- a/src/keymap/openRepositoryModal.ts +++ b/src/keymap/openRepositoryModal.ts @@ -3,6 +3,7 @@ import { context } from "@ghui/keymap" export interface OpenRepositoryModalCtx { readonly closeModal: () => void readonly openFromInput: () => void + readonly moveSelection: (delta: -1 | 1) => void } const OpenRepo = context() @@ -10,4 +11,6 @@ const OpenRepo = context() export const openRepositoryModalKeymap = OpenRepo( { id: "open-repo.close", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() }, { id: "open-repo.open", title: "Open repository", keys: ["return"], run: (s) => s.openFromInput() }, + { id: "open-repo.up", title: "Up", keys: ["k", "up", "ctrl+p", "ctrl+k"], run: (s) => s.moveSelection(-1) }, + { id: "open-repo.down", title: "Down", keys: ["j", "down", "ctrl+n", "ctrl+j"], run: (s) => s.moveSelection(1) }, ) diff --git a/src/ui/modals.tsx b/src/ui/modals.tsx index 53a88db..4135e4b 100644 --- a/src/ui/modals.tsx +++ b/src/ui/modals.tsx @@ -137,6 +137,7 @@ export interface CommandPaletteState { export interface OpenRepositoryModalState { readonly query: string + readonly selectedIndex: number readonly error: string | null } @@ -421,6 +422,7 @@ export const initialCommandPaletteState: CommandPaletteState = { export const initialOpenRepositoryModalState: OpenRepositoryModalState = { query: "", + selectedIndex: 0, error: null, } @@ -463,38 +465,43 @@ export const modalInitialStates = { export const OpenRepositoryModal = ({ state, + suggestions, modalWidth, modalHeight, offsetLeft, offsetTop, }: { state: OpenRepositoryModalState + suggestions: readonly string[] modalWidth: number modalHeight: number offsetLeft: number offsetTop: number }) => { - const { contentWidth } = standardModalDims(modalWidth, modalHeight) - const inputText = state.query.length > 0 ? state.query : "owner/name or GitHub URL" + const { bodyHeight: maxVisible, rowWidth } = searchModalDims(modalWidth, modalHeight) + const normalized = state.query.trim().toLowerCase() + const filtered = normalized.length === 0 ? suggestions : suggestions.filter((s) => s.toLowerCase().includes(normalized)) + const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1)) + const scrollStart = Math.min(Math.max(0, filtered.length - maxVisible), Math.max(0, selectedIndex - maxVisible + 1)) + const visible = filtered.slice(scrollStart, scrollStart + maxVisible) + const countText = `${filtered.length}/${suggestions.length}` + const messageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2)) + const messageBottomRows = Math.max(0, maxVisible - messageTopRows - 1) return ( - - - 0 ? colors.text : colors.muted}>{fitCell(inputText, Math.max(1, contentWidth - 2))} - - } - bodyPadding={1} + query={state.query} + placeholder="owner/name or GitHub URL" + countText={countText} footer={ {state.error ? ( - + + ) : visible.length === 0 ? ( + <> + + 0 ? "No matching remotes" : "Type owner/name or a GitHub URL", rowWidth)} fg={colors.muted} /> + + ) : ( - + visible.map((repo, index) => { + const actualIndex = scrollStart + index + const isSelected = actualIndex === selectedIndex + return ( + + {isSelected ? "▸" : " "} + {fitCell(repo, Math.max(1, rowWidth - 2))} + + ) + }) )} - + ) } diff --git a/test/gitRemotes.test.ts b/test/gitRemotes.test.ts new file mode 100644 index 0000000..26a522b --- /dev/null +++ b/test/gitRemotes.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import { detectGitHubRemotes, parseGitRemoteUrl } from "../src/gitRemotes.js" + +describe("parseGitRemoteUrl", () => { + test("parses https github url", () => { + expect(parseGitRemoteUrl("https://github.com/kitlangton/ghui.git")).toBe("kitlangton/ghui") + }) + + test("parses https github url without .git", () => { + expect(parseGitRemoteUrl("https://github.com/kitlangton/ghui")).toBe("kitlangton/ghui") + }) + + test("parses ssh github url", () => { + expect(parseGitRemoteUrl("git@github.com:kitlangton/ghui.git")).toBe("kitlangton/ghui") + }) + + test("parses ssh github url without .git", () => { + expect(parseGitRemoteUrl("git@github.com:kitlangton/ghui")).toBe("kitlangton/ghui") + }) + + test("returns null for non-github url", () => { + expect(parseGitRemoteUrl("https://gitlab.com/kitlangton/ghui.git")).toBeNull() + }) + + test("returns null for ssh non-github url", () => { + expect(parseGitRemoteUrl("git@gitlab.com:kitlangton/ghui.git")).toBeNull() + }) +}) + +describe("detectGitHubRemotes", () => { + test("returns empty array when not in a git repo", async () => { + const originalCwd = process.cwd() + process.chdir("/tmp") + try { + const remotes = await detectGitHubRemotes() + expect(remotes).toEqual([]) + } finally { + process.chdir(originalCwd) + } + }) + + test("returns remotes from current repo", async () => { + const remotes = await detectGitHubRemotes() + // This test runs inside the ghui repo which is on GitHub + expect(remotes.length).toBeGreaterThan(0) + expect(remotes[0]).toMatch(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/) + }) +})