{
- props.onOpenFile(item.path)
- }}
- >
-
-
- {item.path}
-
-
-
- {props.t("instanceShell.gitChanges.deleted")}
-
-
- <>
- +{item.added}
- -{item.removed}
- >
-
-
+ const renderListItem = (item: GitChangeListItem) => {
+ const isBulkSelected = createMemo(() => props.selectedBulkItemIds().has(item.id))
+ const actionLabel =
+ item.section === "staged"
+ ? props.t("instanceShell.gitChanges.actions.unstage")
+ : props.t("instanceShell.gitChanges.actions.stage")
+
+ const triggerAction = () => {
+ if (item.section === "staged") props.onUnstageFile(item)
+ else props.onStageFile(item)
+ }
+
+ return (
+
{
+ if (event.shiftKey || event.ctrlKey || event.metaKey) {
+ event.preventDefault()
+ }
+ }}
+ onClick={(event) => props.onRowClick(item, event)}
+ title={item.path}
+ >
+
+
+ {item.path}
+
+
+
+ +{item.additions}
+ -{item.deletions}
- )}
-
-
+
+
+
+
+
+
+
+ )
+ }
+
+ const renderSection = (
+ title: string,
+ items: GitChangeListItem[],
+ isOpen: boolean,
+ onToggle: () => void,
+ ) => (
+
+
+
+
+ {(item) => renderListItem(item)}
+
+
+
)
- const renderListOverlay = () => (
-
0} fallback={renderEmptyList()}>
-
- {(item) => (
- props.onOpenFile(item.path)}
- title={item.path}
- >
-
-
- {item.path}
-
-
-
- {props.t("instanceShell.gitChanges.deleted")}
-
-
- <>
- +{item.added}
- -{item.removed}
- >
+ const renderGroupedList = () => (
+ 0} fallback={renderEmptyList()}>
+
+
+
+
+
+
+
{(item) => renderListItem(item)}
-
+
+
+ {renderSection(
+ props.t("instanceShell.gitChanges.sections.unstaged"),
+ unstagedList,
+ props.unstagedOpen(),
+ props.onToggleUnstagedOpen,
)}
-
+
)
@@ -266,7 +386,7 @@ const GitChangesTab: Component
= (props) => {
/>
>
}
- list={{ panel: renderListPanel, overlay: renderListOverlay }}
+ list={{ panel: renderGroupedList, overlay: renderGroupedList }}
viewer={renderViewer()}
listOpen={props.listOpen()}
onToggleList={props.onToggleList}
diff --git a/packages/ui/src/components/instance/shell/right-panel/types.ts b/packages/ui/src/components/instance/shell/right-panel/types.ts
index 3273e5ec..a651383a 100644
--- a/packages/ui/src/components/instance/shell/right-panel/types.ts
+++ b/packages/ui/src/components/instance/shell/right-panel/types.ts
@@ -5,3 +5,40 @@ export type DiffViewMode = "split" | "unified"
export type DiffContextMode = "expanded" | "collapsed"
export type DiffWordWrapMode = "on" | "off"
+
+export type GitChangeStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | string
+
+export interface GitChangeEntry {
+ path: string
+ originalPath?: string | null
+ additions: number
+ deletions: number
+ status: GitChangeStatus
+ stagedStatus?: GitChangeStatus | null
+ unstagedStatus?: GitChangeStatus | null
+ stagedAdditions?: number
+ stagedDeletions?: number
+ unstagedAdditions?: number
+ unstagedDeletions?: number
+}
+
+export type GitChangeSection = "staged" | "unstaged"
+
+export interface GitChangeListItem {
+ id: string
+ path: string
+ originalPath?: string | null
+ section: GitChangeSection
+ status: GitChangeStatus
+ additions: number
+ deletions: number
+ entry: GitChangeEntry
+ displayName: string
+ parentPath: string
+}
+
+export interface GitSelectionDescriptor {
+ itemId: string | null
+ path: string | null
+ section: GitChangeSection | null
+}
diff --git a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts
new file mode 100644
index 00000000..87f37dca
--- /dev/null
+++ b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts
@@ -0,0 +1,470 @@
+import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
+import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
+import type { PromptInputApi } from "../../../prompt-input/types"
+import type { GitChangeEntry, GitChangeListItem, GitSelectionDescriptor, RightPanelTab } from "./types"
+
+import { getOrCreateWorktreeClient } from "../../../../stores/worktrees"
+import { requestData } from "../../../../lib/opencode-api"
+import { serverApi } from "../../../../lib/api-client"
+import { serverEvents } from "../../../../lib/server-events"
+import { showToastNotification } from "../../../../lib/notifications"
+import { adaptSdkGitStatusEntries, buildGitChangeListItems } from "./git-changes-model"
+
+type UseGitChangesOptions = {
+ t: (key: string, vars?: Record) => string
+ instanceId: string
+ rightPanelTab: Accessor
+ worktreeSlug: Accessor
+ isPhoneLayout: Accessor
+ promptInputApi: Accessor
+ closeGitList: () => void
+}
+
+export function useGitChanges(options: UseGitChangesOptions) {
+ const [gitStatusEntries, setGitStatusEntries] = createSignal(null)
+ const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
+ const [gitStatusError, setGitStatusError] = createSignal(null)
+ const [gitSelectedItemId, setGitSelectedItemId] = createSignal(null)
+ const [gitBulkSelectedItemIds, setGitBulkSelectedItemIds] = createSignal>(new Set())
+ const [gitBulkSelectionAnchorId, setGitBulkSelectionAnchorId] = createSignal(null)
+ const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
+ const [gitSelectedError, setGitSelectedError] = createSignal(null)
+ const [gitSelectedBefore, setGitSelectedBefore] = createSignal(null)
+ const [gitSelectedAfter, setGitSelectedAfter] = createSignal(null)
+ const [gitCommitMessage, setGitCommitMessage] = createSignal("")
+ const [gitCommitSubmitting, setGitCommitSubmitting] = createSignal(false)
+ let gitStatusRequestVersion = 0
+ let gitDiffRequestVersion = 0
+ let passiveGitRefreshInFlight = false
+ let pendingGitPassiveRefreshOptions: { forceReloadSelectedDiff?: boolean } | null = null
+ let previousGitChangesActivationKey: string | null = null
+
+ const gitListItems = createMemo(() => buildGitChangeListItems(gitStatusEntries()))
+
+ const clearGitBulkSelection = () => {
+ setGitBulkSelectedItemIds((current) => (current.size === 0 ? current : new Set()))
+ setGitBulkSelectionAnchorId(null)
+ }
+
+ const toggleGitBulkSelection = (itemId: string) => {
+ setGitBulkSelectedItemIds((current) => {
+ const next = new Set(current)
+ if (next.has(itemId)) next.delete(itemId)
+ else next.add(itemId)
+ return next
+ })
+ }
+
+ const addGitBulkRange = (anchorId: string, itemId: string) => {
+ const items = gitListItems()
+ const anchorIndex = items.findIndex((entry) => entry.id === anchorId)
+ const itemIndex = items.findIndex((entry) => entry.id === itemId)
+ if (anchorIndex < 0 || itemIndex < 0) {
+ setGitBulkSelectedItemIds((current) => {
+ const next = new Set(current)
+ next.add(itemId)
+ return next
+ })
+ return
+ }
+
+ const start = Math.min(anchorIndex, itemIndex)
+ const end = Math.max(anchorIndex, itemIndex)
+ const rangeIds = items.slice(start, end + 1).map((entry) => entry.id)
+ setGitBulkSelectedItemIds((current) => {
+ const next = new Set(current)
+ for (const rangeId of rangeIds) {
+ next.add(rangeId)
+ }
+ return next
+ })
+ }
+
+ const describeGitSelection = (itemId: string | null): GitSelectionDescriptor => {
+ if (!itemId) {
+ return { itemId: null, path: null, section: null }
+ }
+ const match = gitListItems().find((item) => item.id === itemId) ?? null
+ return {
+ itemId,
+ path: match?.path ?? null,
+ section: match?.section ?? null,
+ }
+ }
+
+ const gitMostChangedItemId = createMemo(() => {
+ const items = gitListItems()
+ if (items.length === 0) return null
+ const candidates = items.filter((item) => item.status !== "deleted")
+ if (candidates.length === 0) return null
+ const best = candidates.reduce((currentBest, item) => {
+ const bestScore = (currentBest?.additions ?? 0) + (currentBest?.deletions ?? 0)
+ const score = (item.additions ?? 0) + (item.deletions ?? 0)
+ if (score > bestScore) return item
+ if (score < bestScore) return currentBest
+ return String(item.id || "").localeCompare(String(currentBest?.id || "")) < 0 ? item : currentBest
+ }, candidates[0])
+ return typeof best?.id === "string" ? best.id : null
+ })
+
+ const resolveValidGitSelection = (selection: GitSelectionDescriptor): string | null => {
+ const items = gitListItems()
+ if (items.length === 0) return null
+ if (selection.itemId && items.some((item) => item.id === selection.itemId)) return selection.itemId
+ if (selection.path && selection.section) {
+ const oppositeSection = selection.section === "staged" ? "unstaged" : "staged"
+ const moved = items.find((item) => item.path === selection.path && item.section === oppositeSection)
+ if (moved) return moved.id
+ const samePath = items.find((item) => item.path === selection.path)
+ if (samePath) return samePath.id
+ }
+ return gitMostChangedItemId()
+ }
+
+ const describeGitSelectionFingerprint = (itemId: string | null) => {
+ if (!itemId) return null
+ const item = gitListItems().find((entry) => entry.id === itemId) ?? null
+ if (!item) return null
+ return `${item.path}::${item.originalPath ?? ""}::${item.section}::${item.status}::${item.additions}::${item.deletions}`
+ }
+
+ const clearSelectedGitDiff = () => {
+ setGitSelectedError(null)
+ setGitSelectedBefore(null)
+ setGitSelectedAfter(null)
+ }
+
+ const clearSelectedGitDiffAndSelection = () => {
+ setGitSelectedItemId(null)
+ clearGitBulkSelection()
+ setGitSelectedLoading(false)
+ clearSelectedGitDiff()
+ }
+
+ const pruneGitBulkSelection = () => {
+ const validIds = new Set(gitListItems().map((item) => item.id))
+ setGitBulkSelectedItemIds((current) => {
+ if (current.size === 0) return current
+ const next = new Set()
+ for (const itemId of current) {
+ if (validIds.has(itemId)) next.add(itemId)
+ }
+ return next.size === current.size ? current : next
+ })
+
+ const anchorId = gitBulkSelectionAnchorId()
+ if (anchorId && !validIds.has(anchorId)) {
+ setGitBulkSelectionAnchorId(null)
+ }
+ }
+
+ createEffect(() => {
+ gitListItems()
+ pruneGitBulkSelection()
+ })
+
+ const loadGitStatus = async (force = false) => {
+ if (!force && gitStatusEntries() !== null) return
+ const slug = options.worktreeSlug()
+ const client = getOrCreateWorktreeClient(options.instanceId, slug)
+ const requestVersion = ++gitStatusRequestVersion
+ setGitStatusLoading(true)
+ setGitStatusError(null)
+ try {
+ const sdkStatusPromise = requestData(client.file.status(), "file.status")
+ const detailList = await serverApi.fetchWorktreeGitStatus(options.instanceId, slug)
+ if (requestVersion !== gitStatusRequestVersion) return
+ if (slug !== options.worktreeSlug()) return
+
+ const sdkResult = await Promise.race([
+ sdkStatusPromise.then((value) => ({ kind: "fulfilled" as const, value })),
+ new Promise<{ kind: "timeout" }>((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 1500)),
+ ]).catch(() => null)
+
+ const sdkList = sdkResult && sdkResult.kind === "fulfilled" ? sdkResult.value : null
+ setGitStatusEntries(adaptSdkGitStatusEntries(sdkList, detailList))
+ } catch (error) {
+ if (requestVersion !== gitStatusRequestVersion) return
+ if (slug !== options.worktreeSlug()) return
+ setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
+ setGitStatusEntries([])
+ } finally {
+ if (requestVersion !== gitStatusRequestVersion) return
+ if (slug !== options.worktreeSlug()) return
+ setGitStatusLoading(false)
+ }
+ }
+
+ async function openGitFile(itemId: string) {
+ const requestVersion = ++gitDiffRequestVersion
+ setGitSelectedItemId(itemId)
+ setGitSelectedLoading(true)
+ clearSelectedGitDiff()
+
+ const item = gitListItems().find((entry) => entry.id === itemId) || null
+ if (!item) {
+ if (requestVersion !== gitDiffRequestVersion) return
+ clearSelectedGitDiffAndSelection()
+ return
+ }
+
+ if (options.isPhoneLayout()) {
+ options.closeGitList()
+ }
+
+ try {
+ const diff = await serverApi.fetchWorktreeGitDiff(options.instanceId, options.worktreeSlug(), {
+ path: item.path,
+ originalPath: item.originalPath ?? null,
+ scope: item.section,
+ })
+ if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
+ if (diff.isBinary) {
+ setGitSelectedError(options.t("instanceShell.gitChanges.binaryViewer"))
+ return
+ }
+ setGitSelectedBefore(diff.before)
+ setGitSelectedAfter(diff.after)
+ } catch (error) {
+ if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
+ setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
+ } finally {
+ if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
+ setGitSelectedLoading(false)
+ }
+ }
+
+ const passiveRefreshGitStatus = async (optionsArg?: { forceReloadSelectedDiff?: boolean }) => {
+ if (options.rightPanelTab() !== "git-changes") return
+ if (passiveGitRefreshInFlight) {
+ pendingGitPassiveRefreshOptions = {
+ forceReloadSelectedDiff:
+ pendingGitPassiveRefreshOptions?.forceReloadSelectedDiff || optionsArg?.forceReloadSelectedDiff || false,
+ }
+ return
+ }
+ if (gitCommitSubmitting()) return
+
+ passiveGitRefreshInFlight = true
+ const refreshSelectionId = gitSelectedItemId()
+ const previousSelection = describeGitSelection(gitSelectedItemId())
+ const previousFingerprint = describeGitSelectionFingerprint(previousSelection.itemId)
+ const hadSelectedDiff =
+ previousSelection.itemId !== null &&
+ (gitSelectedBefore() !== null || gitSelectedAfter() !== null || gitSelectedError() !== null)
+
+ try {
+ await loadGitStatus(true)
+ if (gitSelectedItemId() !== refreshSelectionId) return
+ const nextSelection = resolveValidGitSelection(previousSelection)
+ setGitSelectedItemId(nextSelection)
+
+ if (!nextSelection) {
+ clearSelectedGitDiff()
+ return
+ }
+
+ const nextFingerprint = describeGitSelectionFingerprint(nextSelection)
+ const shouldReloadSelectedDiff =
+ optionsArg?.forceReloadSelectedDiff ||
+ !hadSelectedDiff ||
+ previousFingerprint !== nextFingerprint ||
+ previousSelection.itemId === nextSelection
+
+ if (shouldReloadSelectedDiff) {
+ await openGitFile(nextSelection)
+ }
+ } finally {
+ passiveGitRefreshInFlight = false
+ if (pendingGitPassiveRefreshOptions) {
+ const nextOptions = pendingGitPassiveRefreshOptions
+ pendingGitPassiveRefreshOptions = null
+ void passiveRefreshGitStatus(nextOptions)
+ }
+ }
+ }
+
+ const mutateGitFile = async (item: GitChangeListItem, action: "stage" | "unstage") => {
+ const currentSelection = describeGitSelection(gitSelectedItemId())
+ const fallbackSelection = currentSelection.path === item.path ? currentSelection : describeGitSelection(item.id)
+ const selectedIds = gitBulkSelectedItemIds()
+ const selectedItems = gitListItems().filter((candidate) => selectedIds.has(candidate.id))
+ const bulkTargets = selectedItems.filter((candidate) => candidate.section === item.section)
+ const targetItems = bulkTargets.some((candidate) => candidate.id === item.id) ? bulkTargets : [item]
+ const targetPaths = Array.from(new Set(targetItems.map((candidate) => candidate.path)))
+ try {
+ if (action === "stage") {
+ await serverApi.stageWorktreeGitPaths(options.instanceId, options.worktreeSlug(), { paths: targetPaths })
+ } else {
+ await serverApi.unstageWorktreeGitPaths(options.instanceId, options.worktreeSlug(), { paths: targetPaths })
+ }
+
+ await loadGitStatus(true)
+ clearGitBulkSelection()
+ const nextSelection = resolveValidGitSelection(fallbackSelection)
+ setGitSelectedItemId(nextSelection)
+ if (nextSelection) {
+ await openGitFile(nextSelection)
+ } else {
+ clearSelectedGitDiff()
+ }
+ } catch (error) {
+ showToastNotification({
+ message: error instanceof Error ? error.message : `Failed to ${action} file`,
+ variant: "error",
+ })
+ }
+ }
+
+ const handleGitRowClick = (item: GitChangeListItem, event: MouseEvent) => {
+ if (event.shiftKey) {
+ event.preventDefault()
+ const anchorId = gitBulkSelectionAnchorId() ?? item.id
+ addGitBulkRange(anchorId, item.id)
+ return
+ }
+
+ if (event.ctrlKey || event.metaKey) {
+ event.preventDefault()
+ toggleGitBulkSelection(item.id)
+ setGitBulkSelectionAnchorId(item.id)
+ return
+ }
+
+ clearGitBulkSelection()
+ setGitBulkSelectionAnchorId(item.id)
+ void openGitFile(item.id)
+ }
+
+ const submitGitCommit = async () => {
+ const message = gitCommitMessage().trim()
+ if (!message || gitCommitSubmitting()) return
+
+ setGitCommitSubmitting(true)
+ try {
+ await serverApi.commitWorktreeGitChanges(options.instanceId, options.worktreeSlug(), { message })
+ setGitCommitMessage("")
+ await loadGitStatus(true)
+ const nextSelection = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
+ setGitSelectedItemId(nextSelection)
+ if (nextSelection) {
+ await openGitFile(nextSelection)
+ } else {
+ clearSelectedGitDiff()
+ }
+ showToastNotification({
+ message: options.t("instanceShell.gitChanges.commit.success"),
+ variant: "success",
+ })
+ } catch (error) {
+ showToastNotification({
+ message: error instanceof Error ? error.message : options.t("instanceShell.gitChanges.commit.error"),
+ variant: "error",
+ })
+ } finally {
+ setGitCommitSubmitting(false)
+ }
+ }
+
+ const refreshGitStatus = async () => {
+ await loadGitStatus(true)
+ const selected = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
+ setGitSelectedItemId(selected)
+ if (selected) {
+ void openGitFile(selected)
+ } else {
+ clearSelectedGitDiff()
+ }
+ }
+
+ const insertGitChangeContext = (item: GitChangeListItem, selection: { startLine: number; endLine: number } | null) => {
+ const startLine = selection?.startLine ?? 1
+ const endLine = selection?.endLine ?? startLine
+ options.promptInputApi()?.insertComment(`Git Diff: File: ${item.path} : ${startLine}-${endLine}`)
+ }
+
+ createEffect(() => {
+ options.worktreeSlug()
+ gitStatusRequestVersion += 1
+ gitDiffRequestVersion += 1
+ passiveGitRefreshInFlight = false
+ pendingGitPassiveRefreshOptions = null
+ setGitStatusEntries(null)
+ setGitStatusError(null)
+ setGitStatusLoading(false)
+ setGitSelectedItemId(null)
+ clearGitBulkSelection()
+ setGitSelectedLoading(false)
+ clearSelectedGitDiff()
+ setGitCommitMessage("")
+ setGitCommitSubmitting(false)
+ })
+
+ createEffect(() => {
+ if (options.rightPanelTab() !== "git-changes") return
+ const items = gitListItems()
+ if (gitStatusEntries() === null) return
+ if (items.length === 0) return
+ if (gitSelectedItemId()) return
+ const next = gitMostChangedItemId()
+ if (!next) return
+ void openGitFile(next)
+ })
+
+ createEffect(() => {
+ const activationKey = options.rightPanelTab() === "git-changes" ? `${options.instanceId}:${options.worktreeSlug()}` : null
+ if (!activationKey) {
+ previousGitChangesActivationKey = null
+ return
+ }
+ if (previousGitChangesActivationKey === activationKey) return
+ previousGitChangesActivationKey = activationKey
+ void passiveRefreshGitStatus()
+ })
+
+ createEffect(() => {
+ if (options.rightPanelTab() !== "git-changes") return
+
+ const unsubscribe = serverEvents.on("instance.event", (event) => {
+ if (event.type !== "instance.event") return
+ if (event.instanceId !== options.instanceId) return
+ const eventType = (event.event as { type?: unknown } | undefined)?.type
+ if (eventType !== "session.updated" && eventType !== "session.diff") return
+ void passiveRefreshGitStatus({ forceReloadSelectedDiff: true })
+ })
+
+ onCleanup(() => {
+ unsubscribe()
+ })
+ })
+
+ createEffect(() => {
+ if (options.rightPanelTab() === "git-changes") return
+ setGitSelectedBefore(null)
+ setGitSelectedAfter(null)
+ setGitSelectedLoading(false)
+ setGitSelectedError(null)
+ })
+
+ return {
+ gitStatusEntries,
+ gitStatusLoading,
+ gitStatusError,
+ gitSelectedItemId,
+ gitBulkSelectedItemIds,
+ gitSelectedLoading,
+ gitSelectedError,
+ gitSelectedBefore,
+ gitSelectedAfter,
+ gitCommitMessage,
+ gitCommitSubmitting,
+ gitMostChangedItemId,
+ setGitCommitMessage,
+ handleGitRowClick,
+ refreshGitStatus,
+ insertGitChangeContext,
+ submitGitCommit,
+ stageGitFile: (item: GitChangeListItem) => void mutateGitFile(item, "stage"),
+ unstageGitFile: (item: GitChangeListItem) => void mutateGitFile(item, "unstage"),
+ }
+}
diff --git a/packages/ui/src/components/instance/shell/storage.ts b/packages/ui/src/components/instance/shell/storage.ts
index b17b6743..6600038f 100644
--- a/packages/ui/src/components/instance/shell/storage.ts
+++ b/packages/ui/src/components/instance/shell/storage.ts
@@ -21,6 +21,10 @@ export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-
export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
+export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-nonphone-v1"
+export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-phone-v1"
+export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-nonphone-v1"
+export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-phone-v1"
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx
index 22601f02..669bfc67 100644
--- a/packages/ui/src/components/prompt-input.tsx
+++ b/packages/ui/src/components/prompt-input.tsx
@@ -120,6 +120,11 @@ export default function PromptInput(props: PromptInputProps) {
insertQuotedSelection(text)
}
},
+ insertComment: (text: string) => {
+ const normalized = (text ?? "").replace(/\r/g, "").trim()
+ if (!normalized) return
+ insertBlockContent(`${normalized}\n\n`)
+ },
expandTextAttachment: (attachmentId: string) => {
const attachment = attachments().find((a) => a.id === attachmentId)
if (!attachment) return
diff --git a/packages/ui/src/components/prompt-input/types.ts b/packages/ui/src/components/prompt-input/types.ts
index e1452ec3..4f2bcffd 100644
--- a/packages/ui/src/components/prompt-input/types.ts
+++ b/packages/ui/src/components/prompt-input/types.ts
@@ -7,6 +7,7 @@ export type PromptInsertMode = "quote" | "code"
export interface PromptInputApi {
insertSelection(text: string, mode: PromptInsertMode): void
+ insertComment(text: string): void
expandTextAttachment(attachmentId: string): void
removeAttachment(attachmentId: string): void
setPromptText(text: string, opts?: { focus?: boolean }): void
diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx
index bcc2b9cc..efa2f5ff 100644
--- a/packages/ui/src/components/session/session-view.tsx
+++ b/packages/ui/src/components/session/session-view.tsx
@@ -36,6 +36,7 @@ interface SessionViewProps {
onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean
isActive?: boolean
+ registerSessionPromptApi?: (sessionId: string, api: PromptInputApi | null) => void
}
export const SessionView: Component = (props) => {
@@ -149,6 +150,7 @@ export const SessionView: Component = (props) => {
function registerPromptInputApi(api: PromptInputApi) {
promptInputApi = api
+ props.registerSessionPromptApi?.(props.sessionId, api)
if (pendingPromptText) {
api.setPromptText(pendingPromptText, { focus: true })
@@ -163,6 +165,7 @@ export const SessionView: Component = (props) => {
return () => {
if (promptInputApi === api) {
promptInputApi = null
+ props.registerSessionPromptApi?.(props.sessionId, null)
}
}
}
diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts
index 20d0f74a..96dad79e 100644
--- a/packages/ui/src/lib/api-client.ts
+++ b/packages/ui/src/lib/api-client.ts
@@ -15,6 +15,11 @@ import type {
RemoteServerProbeRequest,
RemoteServerProbeResponse,
VoiceModeStateResponse,
+ WorktreeGitCommitRequest,
+ WorktreeGitCommitResponse,
+ WorktreeGitDiffRequest,
+ WorktreeGitMutationResponse,
+ WorktreeGitPathsRequest,
WorkspaceCreateRequest,
WorkspaceDescriptor,
WorkspaceFileResponse,
@@ -26,6 +31,8 @@ import type {
WorktreeListResponse,
WorktreeMap,
WorktreeCreateRequest,
+ WorktreeGitDiffResponse,
+ WorktreeGitStatusResponse,
} from "../../../server/src/api-types"
import { getClientIdentity } from "./client-identity"
import { getLogger } from "./logger"
@@ -98,6 +105,25 @@ function logHttp(message: string, context?: Record) {
httpLogger.info(message)
}
+async function readErrorMessage(response: Response): Promise {
+ const text = await response.text()
+ if (!text) return `Request failed with ${response.status}`
+
+ try {
+ const parsed = JSON.parse(text) as { error?: unknown; message?: unknown }
+ if (typeof parsed?.error === "string" && parsed.error.trim()) {
+ return parsed.error
+ }
+ if (typeof parsed?.message === "string" && parsed.message.trim()) {
+ return parsed.message
+ }
+ } catch {
+ // Keep the original body for plain-text responses.
+ }
+
+ return text
+}
+
async function request(path: string, init?: RequestInit): Promise {
const url = API_BASE ? new URL(path, API_BASE).toString() : path
const headers = normalizeHeaders(init?.headers)
@@ -112,7 +138,7 @@ async function request(path: string, init?: RequestInit): Promise {
try {
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) {
- const message = await response.text()
+ const message = await readErrorMessage(response)
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
throw new Error(message || `Request failed with ${response.status}`)
}
@@ -141,7 +167,7 @@ async function requestRaw(path: string, init?: RequestInit): Promise {
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) {
- const message = await response.text()
+ const message = await readErrorMessage(response)
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
throw new Error(message || `Request failed with ${response.status}`)
}
@@ -282,6 +308,47 @@ export const serverApi = {
},
)
},
+ fetchWorktreeGitStatus(id: string, slug: string): Promise {
+ return request(
+ `/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-status`,
+ )
+ },
+ fetchWorktreeGitDiff(id: string, slug: string, requestPayload: WorktreeGitDiffRequest): Promise {
+ const params = new URLSearchParams({ path: requestPayload.path, scope: requestPayload.scope })
+ if (requestPayload.originalPath) {
+ params.set("originalPath", requestPayload.originalPath)
+ }
+ return request(
+ `/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-diff?${params.toString()}`,
+ )
+ },
+ stageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise {
+ return request(
+ `/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-stage`,
+ {
+ method: "POST",
+ body: JSON.stringify(payload),
+ },
+ )
+ },
+ unstageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise {
+ return request(
+ `/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-unstage`,
+ {
+ method: "POST",
+ body: JSON.stringify(payload),
+ },
+ )
+ },
+ commitWorktreeGitChanges(id: string, slug: string, payload: WorktreeGitCommitRequest): Promise {
+ return request(
+ `/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-commit`,
+ {
+ method: "POST",
+ body: JSON.stringify(payload),
+ },
+ )
+ },
fetchConfigOwner = Record>(owner: string): Promise {
return request(`/api/storage/config/${encodeURIComponent(owner)}`)
diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts
index d56f481c..10264a42 100644
--- a/packages/ui/src/lib/i18n/messages/en/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/en/instance.ts
@@ -131,6 +131,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Loading git changes...",
"instanceShell.gitChanges.empty": "No git changes yet.",
"instanceShell.gitChanges.deleted": "Deleted",
+ "instanceShell.gitChanges.binaryViewer": "Binary file cannot be displayed",
+ "instanceShell.gitChanges.sections.staged": "Staged Changes",
+ "instanceShell.gitChanges.sections.unstaged": "Changes",
+ "instanceShell.gitChanges.actions.insertContext": "Add to prompt",
+ "instanceShell.gitChanges.actions.stage": "Stage file",
+ "instanceShell.gitChanges.actions.unstage": "Unstage file",
+ "instanceShell.gitChanges.commit.placeholder": "Enter commit message",
+ "instanceShell.gitChanges.commit.submit": "Commit",
+ "instanceShell.gitChanges.commit.submitting": "Committing...",
+ "instanceShell.gitChanges.commit.success": "Commit created successfully",
+ "instanceShell.gitChanges.commit.error": "Failed to create commit",
"instanceShell.filesShell.fileListTitle": "File list",
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts
index e0293f1f..2b08ba2e 100644
--- a/packages/ui/src/lib/i18n/messages/es/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/es/instance.ts
@@ -130,6 +130,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
"instanceShell.gitChanges.deleted": "Eliminado",
+ "instanceShell.gitChanges.binaryViewer": "No se puede mostrar un archivo binario",
+ "instanceShell.gitChanges.sections.staged": "Cambios preparados",
+ "instanceShell.gitChanges.sections.unstaged": "Cambios",
+ "instanceShell.gitChanges.actions.insertContext": "Agregar al prompt",
+ "instanceShell.gitChanges.actions.stage": "Preparar archivo",
+ "instanceShell.gitChanges.actions.unstage": "Quitar del área preparada",
+ "instanceShell.gitChanges.commit.placeholder": "Escribe el mensaje del commit",
+ "instanceShell.gitChanges.commit.submit": "Commit",
+ "instanceShell.gitChanges.commit.submitting": "Confirmando...",
+ "instanceShell.gitChanges.commit.success": "Commit creado correctamente",
+ "instanceShell.gitChanges.commit.error": "No se pudo crear el commit",
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts
index 26c56844..386958ce 100644
--- a/packages/ui/src/lib/i18n/messages/fr/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts
@@ -130,6 +130,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
"instanceShell.gitChanges.deleted": "Supprimé",
+ "instanceShell.gitChanges.binaryViewer": "Impossible d'afficher un fichier binaire",
+ "instanceShell.gitChanges.sections.staged": "Changements indexés",
+ "instanceShell.gitChanges.sections.unstaged": "Changements",
+ "instanceShell.gitChanges.actions.insertContext": "Ajouter au prompt",
+ "instanceShell.gitChanges.actions.stage": "Indexer le fichier",
+ "instanceShell.gitChanges.actions.unstage": "Retirer de l'index",
+ "instanceShell.gitChanges.commit.placeholder": "Saisissez le message du commit",
+ "instanceShell.gitChanges.commit.submit": "Valider",
+ "instanceShell.gitChanges.commit.submitting": "Validation...",
+ "instanceShell.gitChanges.commit.success": "Commit créé avec succès",
+ "instanceShell.gitChanges.commit.error": "Impossible de créer le commit",
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts
index 1db1b29c..850531f2 100644
--- a/packages/ui/src/lib/i18n/messages/he/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/he/instance.ts
@@ -138,6 +138,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",
+ "instanceShell.gitChanges.binaryViewer": "לא ניתן להציג קובץ בינארי",
+ "instanceShell.gitChanges.sections.staged": "שינויים שנשמרו ל-staging",
+ "instanceShell.gitChanges.sections.unstaged": "שינויים",
+ "instanceShell.gitChanges.actions.insertContext": "הוסף לפרומפט",
+ "instanceShell.gitChanges.actions.stage": "העבר ל-staging",
+ "instanceShell.gitChanges.actions.unstage": "הוצא מ-staging",
+ "instanceShell.gitChanges.commit.placeholder": "הזן הודעת commit",
+ "instanceShell.gitChanges.commit.submit": "Commit",
+ "instanceShell.gitChanges.commit.submitting": "מבצע commit...",
+ "instanceShell.gitChanges.commit.success": "ה-commit נוצר בהצלחה",
+ "instanceShell.gitChanges.commit.error": "יצירת ה-commit נכשלה",
"instanceShell.diff.hideUnchanged": "הסתר אזורים ללא שינוי",
"instanceShell.diff.showFull": "הצג קובץ מלא",
"instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת",
diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts
index 546a22ef..d18e71d3 100644
--- a/packages/ui/src/lib/i18n/messages/ja/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts
@@ -130,6 +130,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
"instanceShell.gitChanges.deleted": "削除済み",
+ "instanceShell.gitChanges.binaryViewer": "バイナリファイルは表示できません",
+ "instanceShell.gitChanges.sections.staged": "ステージ済みの変更",
+ "instanceShell.gitChanges.sections.unstaged": "変更",
+ "instanceShell.gitChanges.actions.insertContext": "プロンプトに追加",
+ "instanceShell.gitChanges.actions.stage": "ファイルをステージ",
+ "instanceShell.gitChanges.actions.unstage": "ステージ解除",
+ "instanceShell.gitChanges.commit.placeholder": "コミットメッセージを入力",
+ "instanceShell.gitChanges.commit.submit": "コミット",
+ "instanceShell.gitChanges.commit.submitting": "コミット中...",
+ "instanceShell.gitChanges.commit.success": "コミットを作成しました",
+ "instanceShell.gitChanges.commit.error": "コミットを作成できませんでした",
"instanceShell.filesShell.fileListTitle": "ファイル一覧",
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts
index 8a4b6a89..0988831c 100644
--- a/packages/ui/src/lib/i18n/messages/ru/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts
@@ -130,6 +130,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
"instanceShell.gitChanges.deleted": "Удалено",
+ "instanceShell.gitChanges.binaryViewer": "Невозможно показать бинарный файл",
+ "instanceShell.gitChanges.sections.staged": "Подготовленные изменения",
+ "instanceShell.gitChanges.sections.unstaged": "Изменения",
+ "instanceShell.gitChanges.actions.insertContext": "Добавить в промпт",
+ "instanceShell.gitChanges.actions.stage": "Подготовить файл",
+ "instanceShell.gitChanges.actions.unstage": "Убрать из staging",
+ "instanceShell.gitChanges.commit.placeholder": "Введите сообщение коммита",
+ "instanceShell.gitChanges.commit.submit": "Commit",
+ "instanceShell.gitChanges.commit.submitting": "Создание commit...",
+ "instanceShell.gitChanges.commit.success": "Commit успешно создан",
+ "instanceShell.gitChanges.commit.error": "Не удалось создать commit",
"instanceShell.filesShell.fileListTitle": "Список файлов",
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
index 10675d0a..c45cafc3 100644
--- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
@@ -130,6 +130,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
"instanceShell.gitChanges.deleted": "已删除",
+ "instanceShell.gitChanges.binaryViewer": "无法显示二进制文件",
+ "instanceShell.gitChanges.sections.staged": "已暂存的更改",
+ "instanceShell.gitChanges.sections.unstaged": "更改",
+ "instanceShell.gitChanges.actions.insertContext": "添加到提示词",
+ "instanceShell.gitChanges.actions.stage": "暂存文件",
+ "instanceShell.gitChanges.actions.unstage": "取消暂存",
+ "instanceShell.gitChanges.commit.placeholder": "输入提交信息",
+ "instanceShell.gitChanges.commit.submit": "提交",
+ "instanceShell.gitChanges.commit.submitting": "正在提交...",
+ "instanceShell.gitChanges.commit.success": "提交已成功创建",
+ "instanceShell.gitChanges.commit.error": "无法创建提交",
"instanceShell.filesShell.fileListTitle": "文件列表",
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css
index 8af0cac3..3f78fcc6 100644
--- a/packages/ui/src/styles/panels/right-panel.css
+++ b/packages/ui/src/styles/panels/right-panel.css
@@ -220,7 +220,7 @@
}
.file-list-item {
- @apply px-3 py-2.5 border-b cursor-pointer transition-all duration-150;
+ @apply px-2 py-1 border-b cursor-pointer transition-all duration-150;
border-color: var(--border-base);
background-color: transparent;
}
@@ -234,14 +234,280 @@
}
.file-list-item-active {
- background-color: var(--surface-base);
+ background-color: color-mix(in srgb, var(--surface-base) 88%, white);
box-shadow: inset 0 0 0 1px var(--accent-primary);
}
+.git-change-list-item-bulk-selected {
+ background-color: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-base));
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-primary) 45%, transparent);
+}
+
+.git-change-list-item-bulk-selected:hover {
+ background-color: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-base));
+}
+
+.git-change-list-item-bulk-selected.file-list-item-active {
+ background-color: color-mix(in srgb, var(--accent-primary) 18%, var(--surface-base));
+ box-shadow:
+ inset 0 0 0 1px var(--accent-primary),
+ inset 0 0 0 2px color-mix(in srgb, var(--accent-primary) 20%, transparent);
+}
+
.file-list-item-content {
@apply flex items-center justify-between gap-3;
}
+.git-change-list-item .file-list-item-content {
+ gap: 0.5rem;
+}
+
+.git-change-sections {
+ @apply flex flex-col;
+}
+
+.git-change-commit-box {
+ @apply flex flex-col gap-2 px-2 py-2 border-b;
+ border-color: var(--border-base);
+ background-color: var(--surface-secondary);
+}
+
+.git-change-commit-input-wrap {
+ position: relative;
+}
+
+.git-change-commit-input {
+ @apply w-full min-h-[32px] px-2 py-1.5 pr-20 text-xs rounded border border-base resize-y;
+ background-color: var(--surface-base);
+ color: var(--text-primary);
+}
+
+.git-change-commit-input::placeholder {
+ color: var(--text-muted);
+}
+
+.git-change-commit-button {
+ @apply inline-flex items-center justify-center self-start px-3 py-1.5 text-xs font-medium rounded border border-base transition-colors;
+ background-color: var(--surface-base);
+ color: var(--text-primary);
+}
+
+.git-change-commit-button-overlay {
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ z-index: 1;
+ align-self: auto;
+ padding: 0.25rem 0.5rem;
+}
+
+.git-change-commit-button-overlay:focus-visible {
+ outline: 2px solid var(--accent-primary);
+ outline-offset: 1px;
+}
+
+.git-change-commit-button-overlay:hover {
+ background-color: var(--surface-hover);
+}
+
+.git-change-commit-button:hover:not(:disabled) {
+ background-color: var(--surface-hover);
+}
+
+.git-change-commit-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.git-change-section {
+ @apply border-b last:border-b-0;
+ border-color: var(--border-base);
+}
+
+.git-change-section-header {
+ @apply w-full flex items-center justify-between gap-2 px-2 py-1 text-left;
+ background-color: var(--surface-secondary);
+ color: var(--text-secondary);
+}
+
+.git-change-section-header:hover {
+ background-color: var(--surface-hover);
+ color: var(--text-primary);
+}
+
+.git-change-section-header-main {
+ @apply flex items-center gap-2 min-w-0;
+}
+
+.git-change-section-chevron {
+ @apply inline-flex items-center justify-center shrink-0;
+}
+
+.git-change-section-title {
+ @apply text-[11px] font-semibold uppercase tracking-wide;
+}
+
+.git-change-section-title-row {
+ @apply inline-flex items-center gap-2 min-w-0 flex-wrap;
+}
+
+.git-change-section-badge {
+ flex-shrink: 1;
+ min-width: 0;
+}
+
+.git-change-section-count {
+ @apply text-[10px] px-1.5 py-0.5 rounded-full shrink-0;
+ background-color: var(--surface-base);
+ color: var(--text-muted);
+ border: 1px solid var(--border-base);
+}
+
+.git-change-section-items {
+ @apply flex flex-col;
+}
+
+.git-change-list-item {
+ padding-inline-start: 0.25rem;
+ position: relative;
+}
+
+.git-change-list-item-right {
+ @apply flex items-center shrink-0;
+ min-width: 0;
+}
+
+.git-change-list-item-actions-zone {
+ @apply flex items-center justify-end;
+ position: absolute;
+ inset-block: 0;
+ inset-inline-end: 0;
+ width: 34px;
+ z-index: 2;
+}
+
+.git-change-list-item-actions {
+ @apply flex items-center justify-center;
+ width: 32px;
+}
+
+.git-change-list-item .file-list-item-content {
+ padding-inline-end: 2rem;
+}
+
+.git-change-row-action {
+ @apply inline-flex items-center justify-center w-5 h-5 rounded border border-base leading-none;
+ background-color: var(--surface-base);
+ border-color: var(--border-base);
+ color: var(--text-secondary);
+ padding: 0;
+ position: relative;
+ overflow: hidden;
+ transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
+}
+
+.git-change-row-action:hover {
+ background-color: var(--surface-hover);
+ border-color: var(--border-base);
+ color: var(--text-primary);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 10%, transparent);
+}
+
+.file-list-item-active .git-change-row-action,
+.git-change-list-item-bulk-selected .git-change-row-action {
+ background-color: color-mix(in srgb, var(--surface-base) 94%, white);
+ border-color: color-mix(in srgb, var(--accent-primary) 24%, var(--border-base));
+}
+
+.file-list-item-active .git-change-row-action:hover,
+.git-change-list-item-bulk-selected .git-change-row-action:hover {
+ background-color: var(--surface-base);
+ border-color: color-mix(in srgb, var(--accent-primary) 42%, var(--border-base));
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-primary) 24%, transparent);
+}
+
+.git-change-row-action:focus-visible {
+ outline: 2px solid var(--accent-primary);
+ outline-offset: 1px;
+}
+
+.git-change-row-action-glyph {
+ position: relative;
+ display: block;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
+
+.git-change-row-action-bar {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ display: block;
+ background-color: currentColor;
+ border-radius: 999px;
+ transform: translate(-50%, -50%);
+}
+
+.git-change-row-action-bar-horizontal {
+ width: 12px;
+ height: 1.5px;
+}
+
+.git-change-row-action-bar-vertical {
+ width: 1.5px;
+ height: 12px;
+}
+
+@media (hover: none), (pointer: coarse) {
+ .git-change-list-item-actions-zone {
+ width: 34px;
+ }
+}
+
+.git-change-list-item-name {
+ @apply text-[12px] leading-4 min-w-0 overflow-hidden whitespace-nowrap;
+ color: var(--text-primary);
+ text-overflow: ellipsis;
+}
+
+.git-change-list-item-parent {
+ @apply text-[10px] leading-3 min-w-0 overflow-hidden whitespace-nowrap;
+ color: var(--text-muted);
+ text-overflow: ellipsis;
+}
+
+.git-change-context-widget-host {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ transform: translate(-50%, -50%);
+ overflow: visible;
+ z-index: 20;
+ pointer-events: auto;
+}
+
+.git-change-context-widget {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 999px;
+ background-color: var(--accent-primary);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-primary) 70%, white 30%);
+ color: white;
+ font-size: 16px;
+ line-height: 1;
+ font-weight: 500;
+ border: 0;
+ cursor: pointer;
+ padding: 0;
+}
+
.file-list-item-path {
@apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap;
color: var(--text-primary);
@@ -335,6 +601,14 @@
width: 100%;
height: 100%;
direction: ltr;
+ position: relative;
+}
+
+.git-change-context-overlay {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 30;
}
.file-viewer-empty {
@@ -507,6 +781,7 @@
opacity: 0;
transform: scale(0.95);
}
+
to {
opacity: 1;
transform: scale(1);