Skip to content
Draft
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
271 changes: 271 additions & 0 deletions packages/app/src/components/dialog-commit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Switch } from "@opencode-ai/ui/switch"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"

const err = (value: unknown) => {
if (typeof value === "object" && value && "error" in value) {
const next = value as { error?: { message?: string } }
if (next.error?.message) return next.error.message
}
if (value instanceof Error) return value.message
return "Request failed"
}

export function CommitDialog() {
const dialog = useDialog()
const language = useLanguage()
const sdk = useSDK()
const sync = useSync()

const [store, setStore] = createStore({
status: "loading" as "loading" | "ready" | "error",
branch: "",
staged: 0,
unstaged: 0,
hasRemote: false,
githubAvailable: false,
githubAuthenticated: false,
files: 0,
added: 0,
removed: 0,
includeUnstaged: true,
action: "commit" as "commit" | "push" | "pr",
message: "",
submitting: false,
error: undefined as string | undefined,
})

onMount(() => {
Promise.all([
sdk.client.vcs.get({ directory: sdk.directory }),
sdk.client.file.status({ directory: sdk.directory }),
])
.then(([vcs, files]) => {
const info = vcs.data
const list = files.data ?? []
const branch = info?.branch ?? sync.data.vcs?.branch ?? ""
const unstaged = info?.unstaged ?? 0
setStore({
status: "ready",
branch,
staged: info?.staged ?? 0,
unstaged,
hasRemote: info?.hasRemote ?? false,
githubAvailable: info?.github.available ?? false,
githubAuthenticated: info?.github.authenticated ?? false,
files: list.length,
added: list.reduce((sum, item) => sum + (item?.added ?? 0), 0),
removed: list.reduce((sum, item) => sum + (item?.removed ?? 0), 0),
includeUnstaged: unstaged > 0,
message: branch,
})
})
.catch((value: unknown) => {
setStore({ status: "error", error: err(value) })
})
})

const summary = createMemo(() => {
if (store.includeUnstaged) {
return language.t("commit.dialog.summary.all", {
staged: String(store.staged),
unstaged: String(store.unstaged),
})
}

return language.t("commit.dialog.summary.staged", {
staged: String(store.staged),
})
})

const ready = createMemo(() => store.staged + (store.includeUnstaged ? store.unstaged : 0) > 0)

const note = (id: "commit" | "push" | "pr") => {
if (id === "commit") return undefined
if (!store.hasRemote) return language.t("commit.dialog.hint.remote")
if (id === "push") return undefined
if (!store.githubAvailable) return language.t("commit.dialog.hint.github")
if (!store.githubAuthenticated) return language.t("commit.dialog.hint.auth")
}

const options = createMemo(() => {
return [
{
id: "commit" as const,
label: language.t("commit.dialog.action.commit"),
disabled: false,
note: note("commit"),
},
{
id: "push" as const,
label: language.t("commit.dialog.action.push"),
disabled: !store.branch || !!note("push"),
note: note("push"),
},
{
id: "pr" as const,
label: language.t("commit.dialog.action.pr"),
disabled: !store.branch || !!note("pr"),
note: note("pr"),
},
]
})

const submit = async () => {
if (!store.message.trim() || store.submitting) return
setStore("submitting", true)
setStore("error", undefined)

try {
const result = await sdk.client.vcs.commit({
directory: sdk.directory,
vcsCommitInput: {
message: store.message.trim(),
includeUnstaged: store.includeUnstaged,
action: store.action,
},
})
const next = await sdk.client.vcs.get({ directory: sdk.directory })
if (next.data) sync.set("vcs", next.data)

showToast({
variant: "success",
icon: "circle-check",
title:
store.action === "commit"
? language.t("commit.dialog.toast.commit")
: store.action === "push"
? language.t("commit.dialog.toast.push")
: language.t("commit.dialog.toast.pr"),
description: result.data?.url,
})
dialog.close()
} catch (value: unknown) {
setStore("error", err(value))
} finally {
setStore("submitting", false)
}
}

return (
<Dialog title={language.t("commit.dialog.title")}>
<div class="flex flex-col gap-4 px-5 pb-5">
<Show when={store.status === "loading"}>
<div class="flex items-center gap-2 text-12-regular text-text-weak">
<Spinner class="size-3 text-icon-weak" />
<span>{language.t("commit.dialog.loading")}</span>
</div>
</Show>

<Show when={store.status !== "loading"}>
<>
<div class="flex items-center justify-between gap-3">
<span class="text-12-medium text-text-weak">{language.t("commit.dialog.branch")}</span>
<div class="flex items-center gap-2 min-w-0">
<Icon name="branch" size="small" class="text-icon-weak shrink-0" />
<span class="text-14-medium text-text-strong truncate">
{store.branch || language.t("commit.dialog.branchUnknown")}
</span>
</div>
</div>

<div class="flex items-center justify-between gap-3">
<span class="text-12-medium text-text-weak">{language.t("commit.dialog.changes")}</span>
<div class="flex items-center gap-2 text-14-medium text-text-strong">
<span>{language.t("commit.dialog.files", { count: String(store.files) })}</span>
<span class="text-text-success">+{store.added}</span>
<span class="text-text-danger">-{store.removed}</span>
</div>
</div>

<Switch
checked={store.includeUnstaged}
disabled={store.unstaged === 0}
onChange={(value) => setStore("includeUnstaged", value)}
>
{language.t("commit.dialog.includeUnstaged")}
</Switch>

<p class="text-12-regular text-text-weak">{summary()}</p>

<div class="flex flex-col gap-1.5">
<label class="text-12-medium text-text-strong">{language.t("commit.dialog.message")}</label>
<TextField
value={store.message}
onInput={(e) => setStore("message", e.currentTarget.value)}
placeholder={language.t("commit.dialog.placeholder")}
autofocus
onKeyDown={(e: KeyboardEvent) => {
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
submit()
}}
/>
</div>

<div class="flex flex-col gap-2">
<span class="text-12-medium text-text-strong">{language.t("commit.dialog.next")}</span>
<div class="overflow-hidden rounded-md border border-border-weak-base bg-surface-panel">
<For each={options()}>
{(item) => (
<button
type="button"
class="flex w-full items-center justify-between gap-3 border-0 border-b border-border-weak-base bg-transparent px-3 py-3 text-left last:border-b-0 disabled:cursor-not-allowed disabled:text-text-weaker"
classList={{
"bg-surface-raised-base": store.action === item.id,
}}
disabled={item.disabled}
onClick={() => setStore("action", item.id)}
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="text-14-medium">{item.label}</span>
<Show when={item.note}>
<span class="text-12-regular text-text-weak">{item.note}</span>
</Show>
</div>
<Show when={store.action === item.id}>
<Icon name="check-small" size="small" class="text-icon-strong shrink-0" />
</Show>
</button>
)}
</For>
</div>
</div>

<Show when={store.error}>
<div class="flex items-start gap-2 rounded-md border border-border-critical-base bg-surface-critical-weak px-3 py-2.5">
<Icon name="circle-x" size="small" class="text-icon-critical-base shrink-0 mt-px" />
<span class="flex-1 text-13-regular text-text-danger whitespace-pre-wrap break-words">
{store.error}
</span>
</div>
</Show>

<div class="flex items-center justify-end gap-2">
<Button variant="secondary" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button
variant="primary"
onClick={submit}
disabled={store.status !== "ready" || !ready() || !store.message.trim() || store.submitting}
>
{store.submitting ? language.t("commit.dialog.submitting") : language.t("commit.dialog.continue")}
</Button>
</div>
</>
</Show>
</div>
</Dialog>
)
}
17 changes: 17 additions & 0 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
Expand All @@ -23,6 +24,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { CommitDialog } from "../dialog-commit"
import { StatusPopover } from "../status-popover"

const OPEN_APPS = [
Expand Down Expand Up @@ -131,6 +133,7 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
export function SessionHeader() {
const layout = useLayout()
const command = useCommand()
const dialog = useDialog()
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
Expand Down Expand Up @@ -224,6 +227,7 @@ export function SessionHeader() {
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
const canCommit = createMemo(() => !!sync.data.vcs?.branch)

const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
Expand Down Expand Up @@ -435,6 +439,19 @@ export function SessionHeader() {
</TooltipKeybind>

<div class="hidden md:flex items-center gap-1 shrink-0">
<Show when={canCommit()}>
<Tooltip placement="bottom" value={language.t("commit.dialog.title")}>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => dialog.show(() => <CommitDialog />)}
aria-label={language.t("commit.dialog.title")}
>
<Icon size="small" name="branch" />
</Button>
</Tooltip>
</Show>

<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
Expand Down
15 changes: 11 additions & 4 deletions packages/app/src/context/global-sync/event-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,8 +494,15 @@ describe("applyDirectoryEvent", () => {
})

test("updates vcs branch in store and cache", () => {
const [store, setStore] = createStore(baseState())
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
const vcs = {
branch: "main",
staged: 1,
unstaged: 2,
hasRemote: true,
github: { available: true, authenticated: true },
}
const [store, setStore] = createStore(baseState({ vcs }))
const [cacheStore, setCacheStore] = createStore({ value: vcs as State["vcs"] })

applyDirectoryEvent({
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
Expand All @@ -511,8 +518,8 @@ describe("applyDirectoryEvent", () => {
},
})

expect(store.vcs).toEqual({ branch: "feature/test" })
expect(cacheStore.value).toEqual({ branch: "feature/test" })
expect(store.vcs).toEqual({ ...vcs, branch: "feature/test" })
expect(cacheStore.value).toEqual({ ...vcs, branch: "feature/test" })
})

test("routes disposal and lsp events to side-effect handlers", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/context/global-sync/event-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ export function applyDirectoryEvent(input: {
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
if (input.store.vcs?.branch === props.branch) break
const next = { branch: props.branch }
const next = { ...(input.store.vcs ?? {}), branch: props.branch }
input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next)
break
Expand Down
23 changes: 23 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@ export const dict = {
"command.category.suggested": "Suggested",
"command.category.view": "View",
"command.category.project": "Project",
"commit.dialog.title": "Commit your changes",
"commit.dialog.loading": "Loading changes...",
"commit.dialog.branch": "Branch",
"commit.dialog.branchUnknown": "Unavailable",
"commit.dialog.changes": "Changes",
"commit.dialog.files": "{{count}} files",
"commit.dialog.includeUnstaged": "Include unstaged",
"commit.dialog.summary.all": "Will commit {{staged}} staged and {{unstaged}} unstaged changes.",
"commit.dialog.summary.staged": "Will commit {{staged}} staged changes only.",
"commit.dialog.message": "Commit message",
"commit.dialog.placeholder": "Describe your changes",
"commit.dialog.next": "Next steps",
"commit.dialog.action.commit": "Commit",
"commit.dialog.action.push": "Commit and push",
"commit.dialog.action.pr": "Commit and create PR",
"commit.dialog.hint.remote": "Add a remote to enable push and PR actions.",
"commit.dialog.hint.github": "GitHub pull requests are only available for GitHub remotes.",
"commit.dialog.hint.auth": "Authenticate the GitHub CLI to enable PR creation.",
"commit.dialog.continue": "Continue",
"commit.dialog.submitting": "Working...",
"commit.dialog.toast.commit": "Changes committed",
"commit.dialog.toast.push": "Changes committed and pushed",
"commit.dialog.toast.pr": "Changes committed and PR created",
"command.category.provider": "Provider",
"command.category.server": "Server",
"command.category.session": "Session",
Expand Down
Loading
Loading