diff --git a/packages/app/src/components/dialog-commit.tsx b/packages/app/src/components/dialog-commit.tsx new file mode 100644 index 00000000000..ffb5eb30e21 --- /dev/null +++ b/packages/app/src/components/dialog-commit.tsx @@ -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 ( + +
+ +
+ + {language.t("commit.dialog.loading")} +
+
+ + + <> +
+ {language.t("commit.dialog.branch")} +
+ + + {store.branch || language.t("commit.dialog.branchUnknown")} + +
+
+ +
+ {language.t("commit.dialog.changes")} +
+ {language.t("commit.dialog.files", { count: String(store.files) })} + +{store.added} + -{store.removed} +
+
+ + setStore("includeUnstaged", value)} + > + {language.t("commit.dialog.includeUnstaged")} + + +

{summary()}

+ +
+ + 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() + }} + /> +
+ +
+ {language.t("commit.dialog.next")} +
+ + {(item) => ( + + )} + +
+
+ + +
+ + + {store.error} + +
+
+ +
+ + +
+ +
+
+
+ ) +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 495b3234058..44c31ead24d 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -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" @@ -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 = [ @@ -131,6 +133,7 @@ const showRequestError = (language: ReturnType, err: unknown export function SessionHeader() { const layout = useLayout() const command = useCommand() + const dialog = useDialog() const server = useServer() const platform = usePlatform() const language = useLanguage() @@ -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 @@ -435,6 +439,19 @@ export function SessionHeader() {