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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/d18476e7c268bb77.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
75 changes: 58 additions & 17 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -271,6 +272,14 @@ const trimQueueLoadCache = (cache: Partial<Record<string, PullRequestLoad>>) =>
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<Record<string, PullRequestLoad>>
}
const isRateLimitError = (error: unknown): boolean => {
if (typeof error !== "object" || error === null) return false
if (!("_tag" in error) || (error as Record<string, unknown>)._tag !== "CommandError") return false
const detail = (error as Record<string, unknown>).detail
if (typeof detail !== "string") return false
const lower = detail.toLowerCase()
return lower.includes("rate limit")
}
const pullRequestsAtom = githubRuntime
.atom(
GitHubService.use((github) =>
Expand Down Expand Up @@ -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)),
)

Expand Down Expand Up @@ -841,11 +854,12 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
const detailPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const detailHydrationRef = useRef(new Map<string, DetailHydration>())
const refreshGenerationRef = useRef(0)
const didMountQueueModeRef = useRef(false)
const lastPullRequestRefreshAtRef = useRef(0)
const terminalFocusedRef = useRef(true)
const terminalWasBlurredRef = useRef(false)
const pullRequestStatusRef = useRef<LoadStatus>("loading")
const detectedRemotesRef = useRef<readonly string[]>([])
const refreshInFlightRef = useRef(false)
const refreshPullRequestsRef = useRef<(message?: string, options?: { readonly resetTransientState?: boolean }) => void>(() => {})
const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -3134,6 +3173,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
openRepositoryModal: {
closeModal: closeActiveModal,
openFromInput: openRepositoryFromInput,
moveSelection: moveRepositorySelection,
},
commentModal: {
closeModal: closeActiveModal,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3826,6 +3866,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
{openRepositoryModalActive ? (
<OpenRepositoryModal
state={openRepositoryModal}
suggestions={detectedRemotesRef.current}
modalWidth={openRepositoryModalWidth}
modalHeight={openRepositoryModalHeight}
offsetLeft={openRepositoryModalLeft}
Expand Down
1 change: 1 addition & 0 deletions src/appCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export const buildAppCommands = ({
title: "Open repository...",
scope: "View",
subtitle: selectedRepository ? `Current repository: ${selectedRepository}` : "Enter owner/name or a GitHub URL",
shortcut: "shift-o",
keywords: ["repo", "repository", "owner", "github"],
run: actions.openRepositoryPicker,
}),
Expand Down
63 changes: 63 additions & 0 deletions src/gitRemotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const GITHUB_REMOTE_PATTERN = /^(?:https?:\/\/github\.com\/|git@github\.com:)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/

export const parseGitRemoteUrl = (url: string): string | null => {
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<string> => {
try {
const file = Bun.file(".git/config")
if (!(await file.exists())) return ""
return await file.text()
} catch {
return ""
}
}

const parseGitConfigRemotes = (content: string): Map<string, string> => {
const remotes = new Map<string, string>()
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<readonly string[]> => {
const content = await readGitConfig()
if (!content) return []

const remotes = parseGitConfigRemotes(content)
const found = new Map<string, string>() // 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)
}
2 changes: 2 additions & 0 deletions src/keymap/detailView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface DetailViewCtx extends Scrollable {
readonly refresh: () => void
readonly openInBrowser: () => void
readonly copyMetadata: () => void
readonly openRepositoryPicker: () => void
}

const Detail = context<DetailViewCtx>()
Expand All @@ -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() },
)
2 changes: 2 additions & 0 deletions src/keymap/listNav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@ 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") },

// 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
{
Expand Down
3 changes: 3 additions & 0 deletions src/keymap/openRepositoryModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { context } from "@ghui/keymap"
export interface OpenRepositoryModalCtx {
readonly closeModal: () => void
readonly openFromInput: () => void
readonly moveSelection: (delta: -1 | 1) => void
}

const OpenRepo = context<OpenRepositoryModalCtx>()

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) },
)
Loading