gitxp
v0.0.1
Published
An alternative GitHub UI that runs locally on top of the GitHub CLI
Readme
gitxp
An alternative GitHub UI that runs on your own machine. npx gitxp starts a
local server, authenticates through the GitHub CLI you already have set up, and
opens the app in your browser. There is no account to create and no OAuth app to
register.
The goal is an interface that feels like Linear rather than a website: the UI reads from a local store and never blocks on a network round trip, and syncing with GitHub happens in the background.
The long-term scope is the daily GitHub workflow: browsing repos, issues and pull requests, reviewing code, discussions and comments, watching CI, and merging. v0 is narrower, see Roadmap.
Requirements
- Node 22.13 or newer, because the local store uses the built-in
node:sqlitemodule - The GitHub CLI, authenticated with
gh auth login
Usage
npx gitxpOptions:
--port <number>use a specific port instead of a random free one--no-openstart the server without opening a browser
How it works
npx gitxp
|
+-- bin/gitxp.js checks `gh auth status`, picks a free port,
| sets HOST=127.0.0.1, boots the built server
|
+-- .output/server TanStack Start server functions talk to the
| GitHub API using the token from `gh auth token`
|
+-- browser React app that reads and writes a local
collection, synced against GitHub in the backgroundThe build output is a self-contained Node server produced by Nitro. It imports
nothing but node: builtins, so the published package has no runtime
dependencies.
Roadmap
v0 is a complete review loop: a notifications inbox, the pull requests waiting on you, the pull request itself with its diff and CI status, and submitting a review with inline comments, approval or changes requested, and merging.
Build order, risky parts first, each step runnable on its own:
- SQLite store, migration runner, the two zones
- API client: token from
gh, ETags, rate limit accounting - Notifications sync loop, one resource proven end to end
- SSE and the client mirror, so a background write reaches the UI
- Inbox UI: list, preview pane, command palette, row actions
- Mutation queue, proven on mark-as-done
- Review inbox as the second resource
- Pull request detail:
@pierre/diffs, existing review comments, CI status - Review submission: inline comments, approve, request changes, merge
After v0, in rough order: issues, repo and file browsing, discussions.
Out of v0 on purpose: search, projects, Actions logs, wikis, gists, org and team views.
None of it is built yet. What is in src/ today is the project scaffold.
Interface
Three columns: a sidebar of views, the list, and a preview pane. Triage happens without leaving the list. A pull request diff breaks out of that layout and takes the full width.
Keyboard first. Cmd+K opens a command palette covering everything, and single letters act on the focused row. The palette is also where an action lives before it earns a shortcut, so nothing ends up reachable only by mouse.
The visual layer stays on stock shadcn for now. It is the one choice here that is cheap to revisit, because retuning spacing and type tokens later does not touch component structure or focus handling.
Cached rows render immediately, with a thin indicator while a background refresh runs. Skeletons appear only where data is genuinely absent. Applied everywhere, that rule is most of what separates this from a website.
Local sync layer
The UI reads from a SQLite database at ~/.gitxp/, written by the server
process. It uses the built-in node:sqlite module, so it adds no dependency and
the published package stays free of them. One server process means one writer,
sync keeps running while no tab is open, and the cache survives clearing browser
data.
What gets synced
GitHub is too large to mirror, so the store has a boundary:
- Synced in the background: your notifications, pull requests waiting on your review, and the repos you explicitly follow in gitxp.
- Fetched on demand and cached afterwards: file trees, blobs, older comment threads, CI logs, anything you reach by navigating rather than by triage.
Anything outside the synced set is a cache miss on first view. The UI should show it arriving rather than pretending it was already local.
Schema
Each entity keeps GitHub's JSON payload as-is in a raw column, alongside
extracted columns for the fields queries filter and sort on. Scope grows toward
all of GitHub, and keeping the payload whole means adding a view does not need a
migration for every field that view happens to read.
Rows are keyed by GitHub's GraphQL node_id. REST and GraphQL return different
identifiers for the same object and REST payloads carry node_id too, so keying
on it means a row written by a bulk GraphQL pull and a row written by a REST
poll are the same row.
Notification threads are the exception. The REST notifications endpoint returns
no node_id, only a string thread id, so notifications is keyed on that.
The store has two zones. Everything cached from GitHub is disposable, can be
dropped and resynced, and therefore makes most schema changes free. The mutation
queue and your settings cannot be rederived from GitHub, so they live in tables
migrations have to preserve. That split is what makes a reset-cache command safe
to offer. Migrations run off SQLite's user_version.
Scheduling
Each resource is its own job with its own interval, ETag and backoff, rather than one loop polling everything. Checks on a pull request you are looking at want seconds, followed repos want minutes, and whatever is on screen gets a priority lane.
node:sqlite is synchronous, so a large write batch stalls HTTP responses in
the same process. Keep batches small and measure. If the UI stutters, the sync
loop moves to a worker thread.
The client mirror
React runs in the browser and cannot read that SQLite file, so every row the UI renders is copied into browser memory first. That copy is a TanStack DB collection holding the slice the current view needs.
Holding a collection, rather than calling a server function per view, is what allows filtering, sorting and joining to happen in the browser with no round trip. It is why an inbox filter can feel instant. Only the bounded triage set is mirrored this way; everything fetched on demand stays in the plain TanStack Query cache.
The server pushes over Server-Sent Events at /api/events, as a plain
ReadableStream response. A sync that writes rows emits a change signal naming
the resource, and the client refetches that collection through its server
function, applying the result inside TanStack DB's begin, truncate, write
and commit calls. Mutations travel the other way as ordinary server function
calls, so the stream stays one-directional.
The stream carries a signal rather than the changed rows. On localhost the
refetch costs a sub-millisecond round trip and reuses the same query path as the
initial load, which is worth more than the saved bytes. Streaming rows, and
per-subset subscriptions through loadSubset and unloadSubset, are the
refinement if a collection ever grows past what a full refetch can carry.
GitHub --poll--> sync loop --write--> SQLite (~/.gitxp)
|
change signal
|
SSE stream at /api/events
|
client refetches that collection
|
SyncConfig: begin / truncate / write / commit
|
client collection --> ReactWrites
Mutations are queued in the same database and applied in order, so a write survives a page reload. They fall into two classes:
- Optimistic: comments, labels, review submissions, marking a notification done. The local store updates immediately and rolls back if the request fails.
- Confirmed: merging, closing, anything awkward to undo or that branch protection and required checks can reject. The UI waits for GitHub and shows the request in flight.
A queued write can fail long after you navigated away, so failed actions need a persistent surface with retry rather than a toast that disappears.
A review in progress stays local until you submit it. Inline comments accumulate in the queue and reach GitHub as one request that creates the review with its comments, which keeps drafting instant and makes submission atomic.
Staying inside the rate limit
The limit is 5000 requests an hour on REST and on GraphQL, and 30 a minute on search, so search is not used for syncing.
- Background polling uses conditional requests. A 304 does not count against the REST limit.
- The notifications endpoint returns an
X-Poll-Intervalheader saying how often GitHub wants to be polled. Respect it instead of picking an interval. - GraphQL is for composite reads where one query replaces several REST calls, such as a pull request with its reviews and checks.
- CI status is the hungriest part, because check runs change often. Poll it only for a pull request currently on screen, never globally.
Reviewing pull requests
Rendering is @pierre/diffs rather than a hand-built
viewer. It is Apache-2.0, built on Shiki, does stacked and split layouts, and
ships an annotation framework meant for review comments and CI annotations. File
trees come from @pierre/trees.
@pierre/trees is at 1.0.0-beta.6 and depends on a Preact 11 beta, so a
Preact beta ends up bundled next to React 19. Worth watching, and worth pinning
the version.
The data still comes from the REST pull request files endpoint, because GitHub has already computed the patch hunks. That endpoint omits the patch for binary files and for files over its size limit, so those need a fallback rather than an empty view.
The preview pane is narrow, so it shows a stacked diff. Split view is available at full width.
Review comments anchor to a path, a line and a side, mapped onto the annotation framework. Comments whose line no longer exists in the current diff are outdated, and the UI has to show them rather than drop them.
Long lists outside the diff still need virtualizing. @tanstack/react-virtual
is the fit, and is not installed yet because nothing imports it.
Security
The server holds a real GitHub token, so it is only ever reachable from the
machine it runs on. bin/gitxp.js sets HOST=127.0.0.1, because the Nitro
default binds every interface.
The token comes from gh auth token and stays on the server side. Server
functions read it and the browser never receives it. It is re-read on a 401
rather than cached for the life of the process, and the host comes from
gh auth status, so an enterprise host works without extra configuration.
Server functions reject requests whose Origin header is not the app itself.
Any page open in your browser can reach 127.0.0.1, and that is the realistic
attacker. This is not built yet, and needs to exist before the server starts
holding a token.
A local process can read the port and send no Origin at all. Defending against
that would need a secret generated at boot and carried in the URL. That is not
planned.
Development
npm install
npm run dev # vite dev server on port 3000
npm run build # produces .output/
npm start # runs bin/gitxp.js against .output, same path as npx
npm run lint
npm run typecheck
npm run formatnpm run lint does not typecheck. npm run typecheck catches what it misses,
including unsound casts on SQLite rows.
Modules under src/server import with explicit .ts extensions and stay inside
the TypeScript syntax Node can strip, so they run directly without the bundler:
GITXP_HOME=/tmp/gitxp-scratch node some-script.tsPublishing
prepublishOnly runs the build, and the files field ships .output and
bin. Everything else is a build-time concern and lives in devDependencies.
dependencies is empty on purpose. npm installs a package's dependencies when
you run it through npx, so anything listed there is downloaded by every user.
Nitro already inlines the framework into .output, which is about 1.4 MB
against a 278 MB development tree.
Check what would be published before releasing:
npm pack --dry-runProject layout
bin/gitxp.js CLI entry point, boots the built server
src/server/db/ SQLite store, migrations, per-resource sync state
src/server/github/ gh token, API client with ETags and rate limit accounting
src/server/sync/ per-resource sync jobs, scheduler, change emitter
src/functions/ server functions, the boundary the client calls
src/db-collections/ client collections mirroring the local database
src/routes/ file-based routes (TanStack Router)
src/components/ui/ shadcn components, all of them
src/integrations/ TanStack Query setup
.output/ build output, generatedBuilt with
TanStack Start with Nitro, TanStack Router,
Query, DB, Table and Form, Tailwind, and shadcn/ui. Diffs and file trees come
from @pierre/diffs and
@pierre/trees.
Every shadcn component is installed under src/components/ui. The full list is
in AGENTS.md.
