From 36c366a918e3c617dd7c9a309bdc3ade186493df Mon Sep 17 00:00:00 2001 From: Mackie Underdown Date: Tue, 18 Nov 2025 19:39:01 -0500 Subject: [PATCH 1/2] Update PR branch with keyboard shortcut --- .changeset/three-mice-fry.md | 10 ++ bun.lock | 3 + package.json | 3 +- src/CommandArgs.ts | 61 ++++++++++ src/GitHub.ts | 208 +++++++++++++++++++++-------------- src/GitHubApiSchema.ts | 42 +++++++ src/Issues.tsx | 3 +- src/Keybindings.tsx | 40 ++++++- src/PullRequests.tsx | 99 +++++++++++++++-- src/Queries.ts | 6 +- src/ReactQueryEffect.ts | 93 ++++++++++++++++ src/Runtime.ts | 1 + src/Toast.tsx | 30 +++++ src/index.tsx | 36 +++++- 14 files changed, 539 insertions(+), 96 deletions(-) create mode 100644 .changeset/three-mice-fry.md create mode 100644 src/CommandArgs.ts create mode 100644 src/GitHubApiSchema.ts create mode 100644 src/Toast.tsx diff --git a/.changeset/three-mice-fry.md b/.changeset/three-mice-fry.md new file mode 100644 index 0000000..cc6161a --- /dev/null +++ b/.changeset/three-mice-fry.md @@ -0,0 +1,10 @@ +--- +'ghui': minor +--- + +Update PR branch with keyboard shortcut + +On the PRs page, select a PR with up/down arrow navigation and then press the following keys to update the PR branch with latest from the base branch: + +- `M`: `gh pr update-branch {number}` (merge by default) +- `R`: `gh pr update-branch {number} --rebase` diff --git a/bun.lock b/bun.lock index ab6da05..eaf191e 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "effect": "^3.19.3", "react": "^19.2.0", "tiny-invariant": "^1.3.3", + "zustand": "^5.0.8", }, "devDependencies": { "@changesets/cli": "^2.29.7", @@ -636,6 +637,8 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="], + "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], diff --git a/package.json b/package.json index 01a5561..2062780 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "cli-spinners": "^3.3.0", "effect": "^3.19.3", "react": "^19.2.0", - "tiny-invariant": "^1.3.3" + "tiny-invariant": "^1.3.3", + "zustand": "^5.0.8" }, "scripts": { "prepare": "husky", diff --git a/src/CommandArgs.ts b/src/CommandArgs.ts new file mode 100644 index 0000000..f680d04 --- /dev/null +++ b/src/CommandArgs.ts @@ -0,0 +1,61 @@ +import * as Command from '@effect/platform/Command' +import * as Array from 'effect/Array' +import * as Data from 'effect/Data' +import * as Effect from 'effect/Effect' +import * as Option from 'effect/Option' +import * as Redacted from 'effect/Redacted' + +type Value = string | number | Redacted.Redacted + +export class CommandArgs extends Data.TaggedClass('ghui/CommandArgs')<{ + readonly values: Array.NonEmptyReadonlyArray +}> {} + +export class EmptyCommandArgsError extends Data.TaggedError( + 'ghui/CommandArgs/EmptyCommandArgsError' +) {} + +class Builder { + readonly values: Value[] + + constructor(values: Value[]) { + this.values = values + } + + append(...values: Value[]): Builder { + return new Builder(this.values.concat(values)) + } + + appendIf(condition: boolean, lazyValues: () => Value | Value[]): Builder { + if (condition) { + return new Builder(this.values.concat(lazyValues())) + } + return new Builder(this.values) + } + + option(option: Option.Option, mapper: (value: T) => Value[]): Builder { + return Option.match(option, { + onSome: (value) => new Builder(this.values.concat(mapper(value))), + onNone: () => new Builder(this.values), + }) + } + + build(): Effect.Effect { + if (Array.isNonEmptyReadonlyArray(this.values)) { + return Effect.succeed(new CommandArgs({ values: this.values })) + } + return Effect.fail(new EmptyCommandArgsError()) + } +} + +export const builder = (): Builder => new Builder([]) + +export const toCommand = (commandArgs: CommandArgs): Command.Command => + Command.make( + ...Array.map(commandArgs.values, (value) => { + if (Redacted.isRedacted(value)) { + return Redacted.value(value).toString() + } + return value.toString() + }) + ) diff --git a/src/GitHub.ts b/src/GitHub.ts index 28dbf8e..8467c50 100644 --- a/src/GitHub.ts +++ b/src/GitHub.ts @@ -1,38 +1,55 @@ +import * as BunContext from '@effect/platform-bun/BunContext' import * as Command from '@effect/platform/Command' +import { pipe } from 'effect' +import * as Data from 'effect/Data' import * as Effect from 'effect/Effect' import * as Option from 'effect/Option' import * as Schema from 'effect/Schema' +import * as Stream from 'effect/Stream' +import * as String from 'effect/String' -class User extends Schema.Class('User')({ - id: Schema.Number, - login: Schema.String, -}) {} +import * as CommandArgs from './CommandArgs' +import * as GitHubApiSchema from './GitHubApiSchema' -class PullRequest extends Schema.Class('PullRequest')({ - user: User, - created_at: Schema.DateTimeUtc, - head: Schema.Struct({ - repo: Schema.Struct({ - name: Schema.String, - owner: Schema.Struct({ - login: Schema.String, - }), - }), - }), - base: Schema.Struct({ - repo: Schema.Struct({ - name: Schema.String, - owner: Schema.Struct({ - login: Schema.String, - }), - }), - }), - id: Schema.Number, - draft: Schema.Boolean, - number: Schema.Number, - state: Schema.String, - title: Schema.String, -}) {} +export class PermissionError extends Data.TaggedError( + 'ghui/GitHub/PermissionError' +)<{}> {} + +export class UnknownError extends Data.TaggedError('ghui/GitHub/UnknownError')<{ + readonly message: string +}> {} + +const runString = ( + stream: Stream.Stream +): Effect.Effect => + stream.pipe(Stream.decodeText(), Stream.runFold(String.empty, String.concat)) + +const runCommand = Effect.fn(function* (command: Command.Command) { + const [exitCode, stdout, stderr] = yield* pipe( + // Start running the command and return a handle to the running process + Command.start(command), + Effect.flatMap((process) => + Effect.all( + [ + // Waits for the process to exit and returns + // the ExitCode of the command that was run + process.exitCode, + // The standard output stream of the process + runString(process.stdout), + // The standard error stream of the process + runString(process.stderr), + ], + { concurrency: 3 } + ) + ) + ) + + if (exitCode === 0) { + return yield* Effect.succeed(stdout) + } else { + return yield* Effect.fail(new UnknownError({ message: stderr })) + } +}) export class PullRequests extends Effect.Service()( 'ghui/GitHub/PullRequests', @@ -46,20 +63,25 @@ export class PullRequests extends Effect.Service()( author: Option.Option repo: Option.Option }) { - const [gh, ...args] = [ - 'gh', - 'api', - Option.match(repo, { - onSome: (repo) => `repos/${repo}/pulls`, - onNone: () => `repos/{owner}/{repo}/pulls`, - }), - '--paginate', - ] as const + const args = yield* CommandArgs.builder() + .append( + 'gh', + 'api', + Option.match(repo, { + onSome: (repo) => `repos/${repo}/pulls`, + onNone: () => `repos/{owner}/{repo}/pulls`, + }), + '--paginate' + ) + .build() - const result = yield* Command.make(gh, ...args).pipe(Command.string) + const result = yield* CommandArgs.toCommand(args).pipe(Command.string) const response = yield* Schema.decodeUnknown( - Schema.compose(Schema.parseJson(), Schema.Array(PullRequest)) + Schema.compose( + Schema.parseJson(), + Schema.Array(GitHubApiSchema.PullRequest) + ) )(result) return Option.match(author, { @@ -67,46 +89,67 @@ export class PullRequests extends Effect.Service()( onNone: () => response, }) }), + }), + dependencies: [BunContext.layer], + } +) {} + +export class PullRequest extends Effect.Service()( + 'ghui/GitHub/PullRequest', + { + accessors: true, + sync: () => ({ + markdownDescription: Effect.fn('PullRequest.markdownDescription')( + function* ({ number }: { number: number }) { + const args = yield* CommandArgs.builder() + .append('gh', 'pr', 'view', '--json', 'body', number) + .build() - readme: Effect.fn('PullRequests.readme')(function* ({ + const jsonString = yield* Command.string(CommandArgs.toCommand(args)) + + const readme = yield* Schema.decodeUnknown( + Schema.compose( + Schema.parseJson(), + Schema.Struct({ body: Schema.String }) + ) + )(jsonString) + + return readme.body + } + ), + updateBranch: Effect.fn('PullRequest.updateBranch')(function* ({ number, + repo, + type = 'merge', }: { number: number + repo: string + type?: 'rebase' | 'merge' }) { - const args = [ - 'gh', - 'pr', - 'view', - '--json', - 'body', - number.toString(), - ] as const - - const jsonString = yield* Command.string(Command.make(...args)) + const args = yield* CommandArgs.builder() + .append('gh', 'pr', 'update-branch', number, '--repo', repo) + .appendIf(type === 'rebase', () => '--rebase') + .build() - const readme = yield* Schema.decodeUnknown( - Schema.compose( - Schema.parseJson(), - Schema.Struct({ body: Schema.String }) - ) - )(jsonString) - - return readme.body + return yield* CommandArgs.toCommand(args).pipe( + runCommand, + Effect.mapError((error) => { + if (error._tag === 'ghui/GitHub/UnknownError') { + if ( + /does not have the correct permissions/i.test(error.message) + ) { + return new PermissionError() + } + } + return error + }) + ) }), }), + dependencies: [BunContext.layer], } ) {} -class Issue extends Schema.Class('Issue')({ - user: User, - created_at: Schema.DateTimeUtc, - id: Schema.Number, - number: Schema.Number, - title: Schema.String, - body: Schema.OptionFromNullOr(Schema.String), - pull_request: Schema.OptionFromNullOr(Schema.Unknown), -}) {} - export class Issues extends Effect.Service()('ghui/GitHub/Issues', { accessors: true, sync: () => ({ @@ -115,23 +158,26 @@ export class Issues extends Effect.Service()('ghui/GitHub/Issues', { }: { repo: Option.Option }) { - const [gh, ...args] = [ - 'gh', - 'api', - Option.match(repo, { - onSome: (orgRepo) => `repos/${orgRepo}/issues`, - onNone: () => `repos/{owner}/{repo}/issues`, - }), - '--paginate', - ] as const - - const result = yield* Command.make(gh, ...args).pipe(Command.string) + const args = yield* CommandArgs.builder() + .append( + 'gh', + 'api', + Option.match(repo, { + onSome: (orgRepo) => `repos/${orgRepo}/issues`, + onNone: () => `repos/{owner}/{repo}/issues`, + }), + '--paginate' + ) + .build() + + const result = yield* CommandArgs.toCommand(args).pipe(Command.string) const response = yield* Schema.decodeUnknown( - Schema.compose(Schema.parseJson(), Schema.Array(Issue)) + Schema.compose(Schema.parseJson(), Schema.Array(GitHubApiSchema.Issue)) )(result) return response.filter((issue) => Option.isNone(issue.pull_request)) }), + dependencies: [BunContext.layer], }), }) {} diff --git a/src/GitHubApiSchema.ts b/src/GitHubApiSchema.ts new file mode 100644 index 0000000..83272a5 --- /dev/null +++ b/src/GitHubApiSchema.ts @@ -0,0 +1,42 @@ +import * as Schema from 'effect/Schema' + +export class User extends Schema.Class('User')({ + id: Schema.Number, + login: Schema.String, +}) {} + +export class Issue extends Schema.Class('Issue')({ + user: User, + created_at: Schema.DateTimeUtc, + id: Schema.Number, + number: Schema.Number, + title: Schema.String, + body: Schema.OptionFromNullOr(Schema.String), + pull_request: Schema.OptionFromNullOr(Schema.Unknown), +}) {} + +export class PullRequest extends Schema.Class('PullRequest')({ + user: User, + created_at: Schema.DateTimeUtc, + head: Schema.Struct({ + repo: Schema.Struct({ + name: Schema.String, + owner: Schema.Struct({ + login: Schema.String, + }), + }), + }), + base: Schema.Struct({ + repo: Schema.Struct({ + name: Schema.String, + owner: Schema.Struct({ + login: Schema.String, + }), + }), + }), + id: Schema.Number, + draft: Schema.Boolean, + number: Schema.Number, + state: Schema.String, + title: Schema.String, +}) {} diff --git a/src/Issues.tsx b/src/Issues.tsx index f03e1f1..85c5db9 100644 --- a/src/Issues.tsx +++ b/src/Issues.tsx @@ -68,7 +68,7 @@ export const Issues = () => { )} - + {Match.value(issues).pipe( Match.when({ isLoading: true }, () => ), Match.when({ isSuccess: true }, ({ data: issues }) => ( @@ -94,6 +94,7 @@ export const Issues = () => { flexGrow={1} height='100%' border + borderStyle='rounded' borderColor='gray' paddingLeft={2} paddingRight={2} diff --git a/src/Keybindings.tsx b/src/Keybindings.tsx index a8571ce..a535439 100644 --- a/src/Keybindings.tsx +++ b/src/Keybindings.tsx @@ -1 +1,39 @@ -export const Keybindings = () => null +import { TextAttributes } from '@opentui/core' +import * as Match from 'effect/Match' + +import * as View from './View' + +export const Keybindings = ({ view }: { view: View.View }) => ( + + + Keybindings + + + {Match.value(view).pipe( + Match.tagsExhaustive({ + Issues: () => null, + PullRequests: () => ( + + + + M + + {`gh pr update-branch {number}`} + + + + R + + {`gh pr update-branch {number} --rebase`} + + + ), + SplashScreen: () => null, + }) + )} + +) diff --git a/src/PullRequests.tsx b/src/PullRequests.tsx index 0dcdce4..39a8920 100644 --- a/src/PullRequests.tsx +++ b/src/PullRequests.tsx @@ -1,15 +1,23 @@ import { TextAttributes } from '@opentui/core' import { useKeyboard } from '@opentui/react' +import { pipe } from 'effect' +import * as Cause from 'effect/Cause' import * as DateTime from 'effect/DateTime' +import * as Effect from 'effect/Effect' import * as Match from 'effect/Match' import * as Option from 'effect/Option' -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import invariant from 'tiny-invariant' +import * as GitHub from './GitHub' import { Loading } from './Loading' import * as Queries from './Queries' import * as RQE from './ReactQueryEffect' import { useCurrentRepo } from './RepoProvider' +import { AppRuntime } from './Runtime' +import { useToast } from './Toast' + +const elementOrder = ['list', 'detail'] as const export const PullRequests = ({ author: initialAuthor, @@ -27,6 +35,9 @@ export const PullRequests = ({ }) ) + const [elementFocusIndex, setElementFocusIndex] = useState(0) + const focusedElement = elementOrder[elementFocusIndex % elementOrder.length] + const [selectedPrNumber, setSelectedPrNumber] = useState< Option.Option >(Option.none()) @@ -44,18 +55,81 @@ export const PullRequests = ({ } }, [pulls.isSuccess, pulls.data]) - const readme = RQE.useQuery( - Queries.pullRequestReadme({ + const description = RQE.useQuery( + Queries.pullRequestMarkdownDescription({ number: selectedPrNumber, repo: Option.none(), }) ) - const shiftFocus = useCallback(() => {}, []) + const showToast = useToast((state) => state.showToast) + + const updateBranch = RQE.useMutation({ + mutationFn: (input: { + repo: string + number: number + type: 'rebase' | 'merge' + }) => + AppRuntime.runPromiseExit( + GitHub.PullRequest.updateBranch(input).pipe(Effect.scoped) + ), + onSuccess(_, { repo, number, type }) { + showToast({ + kind: 'success', + message: `${repo}#${number} updated with ${type}`, + }) + }, + onError(error, { repo, number }) { + const message = pipe( + error.cause, + Cause.map((e) => + Match.value(e).pipe( + Match.when( + Match.instanceOf(GitHub.PermissionError), + () => `No permission to update ${repo}#${number}` + ), + Match.orElse(() => `Unable to update ${repo}#${number}`) + ) + ), + Cause.pretty + ) + + console.log(error) + + showToast({ kind: 'danger', message }) + }, + }) useKeyboard((key) => { if (key.name === 'tab') { - shiftFocus() + const direction = key.shift ? -1 : 1 + setElementFocusIndex((prev) => prev + direction) + } + if (key.name === 'm' && key.shift) { + Match.value([repo, selectedPrNumber]).pipe( + Match.when( + [{ isSuccess: true, data: Option.isSome }, Option.isSome], + ([repo, number]) => + updateBranch.mutate({ + repo: repo.data.value, + number: number.value, + type: 'merge', + }) + ) + ) + } + if (key.name === 'r' && key.shift) { + Match.value([repo, selectedPrNumber]).pipe( + Match.when( + [{ isSuccess: true, data: Option.isSome }, Option.isSome], + ([repo, number]) => + updateBranch.mutate({ + repo: repo.data.value, + number: number.value, + type: 'rebase', + }) + ) + ) } }) @@ -82,12 +156,17 @@ export const PullRequests = ({ )} - + {Match.value(pulls).pipe( Match.when({ isLoading: true }, () => ), Match.when({ isSuccess: true }, ({ data: prs }) => (