From 9bf4d351ded839ac37e82640abdf0a3d88401ccc Mon Sep 17 00:00:00 2001 From: VooDisss <41582720+VooDisss@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:11:48 +0300 Subject: [PATCH] Refactor Git Changes workflow and diff handling (#311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Git Changes PR Review Context Fixes: #310 ## Purpose of this document This document is intended to give a PR reviewer or gatekeeper enough neutral context to review the Git Changes feature series accurately. ## BEFORE/AFTER SNAPSHOT: image It distinguishes: 1. the intended scope of the work 2. implementation choices that were deliberate 3. behaviors that were explicitly tested and accepted during development 4. remaining follow-up areas that were not part of the required intent It should not be treated as a request to approve the PR automatically. It exists to reduce false-positive review findings caused by missing context. --- ## High-level scope The work in this series refactors and extends the existing `Git Changes` tab in the right panel. The intended feature scope includes: 1. grouped staged / unstaged change presentation 2. correct section-aware diff loading 3. per-file stage / unstage controls 4. commit message compose box and commit action for staged changes 5. prompt-context insertion from the Git diff viewer 6. auto-refresh behavior that reduces dependence on the manual refresh button This work is intentionally implemented inside the existing Git Changes vertical slice rather than as a new SCM subsystem. --- ## Files and areas intentionally changed ### Server / API surface The following server areas were intentionally extended: 1. `packages/server/src/api-types.ts` 2. `packages/server/src/events/bus.ts` 3. `packages/server/src/server/http-server.ts` 4. `packages/server/src/server/routes/workspaces.ts` 5. `packages/server/src/workspaces/git-status.ts` 6. `packages/server/src/workspaces/git-mutations.ts` 7. `packages/server/src/workspaces/worktree-directory.ts` 8. `packages/server/src/workspaces/instance-events.ts` ### UI surface The following UI areas were intentionally extended: 1. `packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx` 2. `packages/ui/src/components/instance/instance-shell2.tsx` 3. `packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx` 4. `packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts` 5. `packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx` 6. `packages/ui/src/components/instance/shell/right-panel/types.ts` 7. `packages/ui/src/components/instance/shell/storage.ts` 8. `packages/ui/src/components/prompt-input.tsx` 9. `packages/ui/src/components/prompt-input/types.ts` 10. `packages/ui/src/components/session/session-view.tsx` 11. `packages/ui/src/lib/api-client.ts` 12. `packages/ui/src/lib/i18n/messages/*/instance.ts` 13. `packages/ui/src/styles/panels/right-panel.css` --- ## Intentional product and architecture decisions The following outcomes were deliberate and should not be flagged as issues merely because they exist. ### Git status / diff architecture 1. The UI does not rely only on the proxied OpenCode `file.status()` payload. 2. CodeNomad adds server-backed worktree Git status and diff endpoints to expose staged / unstaged semantics correctly. 3. Server-backed worktree mutation endpoints were added for: - stage - unstage - commit 4. The existing event bus / SSE channel is reused for Git invalidation, instead of adding a bespoke invalidation route. ### Git Changes UI structure 1. The file list is grouped into: - `Staged Changes` - `Changes` 2. Both sections are collapsible. 3. Section open state is persisted. 4. The same file may appear in both sections when Git state genuinely requires that. 5. Rows are filename-first, with parent path as secondary text. 6. Rows are intentionally compact compared to the original flat list. ### Diff behavior 1. Diff loading is section-aware. 2. Deleted files are supported in grouped mode. 3. Binary files are treated as non-line-oriented in the diff viewer. 4. Binary diffs suppress line-based prompt-context affordances. ### Stage / unstage / commit workflow 1. Stage and unstage are per-file row actions. 2. Bulk stage-all / unstage-all was intentionally not added. 3. The commit compose box is intentionally rendered inside the `Staged Changes` section. 4. The commit button is intentionally overlaid inside the commit input area. 5. The current commit compose flow is minimal by design: - no push - no amend flow - no branch management ### Prompt-context insertion 1. Prompt insertion is intentionally an HTML comment marker, not a full diff payload. 2. The expected inserted form is: `` 3. The trigger UI is intentionally a seam/gutter action in the Monaco diff viewer, not a toolbar button. ### Row action reveal behavior 1. Stage / unstage row actions are intentionally hover-revealed on hover-capable layouts. 2. The row action reveal intentionally uses: - delayed hide - slight stats fade/shift - compact idle width 3. On non-hover layouts, the action remains visible for reliability. ### Auto-refresh behavior The accepted refresh model is intentionally hybrid: 1. refresh on Git Changes tab activation 2. 20-second polling only while the Git Changes tab is active 3. immediate invalidation from completed raw tool events for: - `write` - `edit` - `apply_patch` This hybrid model is intentional. Polling remains as a fallback even after tool-event invalidation. --- ## Behaviors explicitly tested during development The following behaviors were explicitly exercised during development and used to guide fixes. ### Grouped staged / unstaged behavior 1. files appear in the correct staged / unstaged sections 2. section collapse / expand works 3. collapse state persists 4. line counts are section-specific ### Diff behavior 1. staged diff loads differently from unstaged diff 2. deleted-file handling was verified and corrected 3. binary-file rendering was corrected to avoid line-oriented behavior 4. untracked binary files no longer report fake text line counts ### Mutation behavior 1. per-file stage works from `Changes` 2. per-file unstage works from `Staged Changes` 3. stage / unstage selection remapping was exercised and corrected 4. unborn-repo unstage behavior was explicitly hardened ### Prompt-context behavior 1. selected line / range insertion was tested 2. button placement in the Monaco seam/gutter was iterated and verified ### Auto-refresh behavior 1. tab-activation refresh was tested 2. 20-second active-tab polling was tested 3. raw completed tool invalidation was tested in the running UI for: - `write` - `edit` - `apply_patch` 4. stale async overwrite and stale selection restoration bugs were found and fixed through review/testing --- ## Review findings that were investigated and are no longer intended blocker topics The following areas were previously raised by strict reviews and then either fixed or determined to be acceptable within scope. ### Fixed in the current series 1. duplicate stage / unstage firing 2. stale diff response overwriting newer selection 3. passive refresh restoring a stale selection 4. instance-wide invalidation overreach 5. selected diff staying stale after tool invalidation 6. worktree-switch status races 7. unhandled rejection risk from async invalidation publication 8. queued invalidation intent being lost during in-flight refresh 9. `git-diff` path traversal / absolute path boundary issue ### Investigated and considered non-blocking within current intent 1. split add/delete presentation for tracked rename behavior - this was compared against VS Code behavior during manual testing - no stage/unstage corruption was observed in the tested flow - this is currently treated as a representation tradeoff, not a proven blocker --- ## Remaining non-blocker follow-up areas The following are still reasonable follow-up topics, but they were not part of the required blocker-fix scope. 1. normalize directory-to-worktree matching more aggressively on Windows so tool invalidation works more reliably from nested directories or path-format variations 2. improve keyboard discoverability of hover-revealed stage / unstage actions 3. reserve textarea space for the overlaid commit button if the overlay tradeoff is reconsidered 4. reduce size/complexity in: - `RightPanel.tsx` - `right-panel.css` 5. tighten raw SSE tool-event parsing into a more explicit helper if that event bridge grows further These follow-ups should not be interpreted as evidence that the core implementation is incomplete unless a reviewer finds a new concrete failure. --- ## Suggested review focus If a gatekeeper or reviewer is evaluating this PR, the most useful focus areas are: 1. whether staged / unstaged behavior is correct for normal Git workflows 2. whether the new server worktree Git endpoints remain narrowly scoped 3. whether auto-refresh remains bounded to the active Git Changes context 4. whether the explicit fixes for stale async behavior and invalidation races are sufficient 5. whether any unintentional server boundary broadening or state corruption remains Less useful review topics, unless tied to a concrete failure, are: 1. preference disagreements with accepted prompt insertion format 2. preference disagreements with the overlaid commit button placement 3. preference disagreements with keeping polling fallback alongside tool invalidation 4. objections to server-backed Git endpoints purely because they add surface area --- ## Summary This series intentionally evolves the existing Git Changes tab into a more complete source-control workflow for: 1. grouped staged / unstaged inspection 2. section-aware diffs 3. per-file staging and unstaging 4. commit composition for staged changes 5. prompt-context insertion from Git diffs 6. bounded auto-refresh for both passive viewing and agent-driven file mutations The intended review standard is to find concrete correctness, layering, or maintenance problems that remain after this series — not to re-argue the already accepted product choices listed above. --------- Co-authored-by: Shantur Rathore --- packages/server/src/api-types.ts | 49 ++ packages/server/src/server/http-server.ts | 47 +- .../server/src/server/routes/workspaces.ts | 146 ++++++ .../server/src/workspaces/git-mutations.ts | 121 +++++ packages/server/src/workspaces/git-status.ts | 385 ++++++++++++++ .../server/src/workspaces/git-worktrees.ts | 21 +- .../src/workspaces/worktree-directory.ts | 99 ++++ .../file-viewer/monaco-diff-viewer.tsx | 169 ++++++- .../components/instance/instance-shell2.tsx | 18 + .../instance/shell/right-panel/RightPanel.tsx | 256 ++++------ .../shell/right-panel/git-changes-model.ts | 148 ++++++ .../shell/right-panel/tabs/GitChangesTab.tsx | 276 +++++++--- .../instance/shell/right-panel/types.ts | 37 ++ .../shell/right-panel/useGitChanges.ts | 470 ++++++++++++++++++ .../src/components/instance/shell/storage.ts | 4 + packages/ui/src/components/prompt-input.tsx | 5 + .../ui/src/components/prompt-input/types.ts | 1 + .../src/components/session/session-view.tsx | 3 + packages/ui/src/lib/api-client.ts | 71 ++- .../ui/src/lib/i18n/messages/en/instance.ts | 11 + .../ui/src/lib/i18n/messages/es/instance.ts | 11 + .../ui/src/lib/i18n/messages/fr/instance.ts | 11 + .../ui/src/lib/i18n/messages/he/instance.ts | 11 + .../ui/src/lib/i18n/messages/ja/instance.ts | 11 + .../ui/src/lib/i18n/messages/ru/instance.ts | 11 + .../src/lib/i18n/messages/zh-Hans/instance.ts | 11 + packages/ui/src/styles/panels/right-panel.css | 279 ++++++++++- 27 files changed, 2403 insertions(+), 279 deletions(-) create mode 100644 packages/server/src/workspaces/git-mutations.ts create mode 100644 packages/server/src/workspaces/git-status.ts create mode 100644 packages/server/src/workspaces/worktree-directory.ts create mode 100644 packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts create mode 100644 packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index b2a1e577..f46796b4 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -81,6 +81,55 @@ export interface WorktreeMap { parentSessionWorktreeSlug: Record } +export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged" + +export interface WorktreeGitStatusEntry { + path: string + originalPath?: string | null + stagedStatus: GitChangeKind | null + stagedAdditions: number + stagedDeletions: number + unstagedStatus: GitChangeKind | null + unstagedAdditions: number + unstagedDeletions: number +} + +export type WorktreeGitStatusResponse = WorktreeGitStatusEntry[] + +export type WorktreeGitDiffScope = "staged" | "unstaged" + +export interface WorktreeGitPathsRequest { + paths: string[] +} + +export interface WorktreeGitMutationResponse { + ok: true +} + +export interface WorktreeGitCommitRequest { + message: string +} + +export interface WorktreeGitCommitResponse { + ok: true + commitSha?: string +} + +export interface WorktreeGitDiffResponse { + path: string + originalPath?: string | null + scope: WorktreeGitDiffScope + before: string + after: string + isBinary?: boolean +} + +export interface WorktreeGitDiffRequest { + path: string + originalPath?: string | null + scope: WorktreeGitDiffScope +} + export type LogLevel = "debug" | "info" | "warn" | "error" export interface WorkspaceLogEntry { diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index ee5355c5..9d4e0e1a 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -10,6 +10,7 @@ import { fetch } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" +import { resolveWorktreeDirectory } from "../workspaces/worktree-directory" import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" @@ -760,52 +761,6 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) { return trimmed.length === 0 ? "/" : `/${trimmed}` } -type WorktreeCacheEntry = { - expiresAt: number - repoRoot: string - worktrees: Array<{ slug: string; directory: string }> -} - -const WORKTREE_CACHE_TTL_MS = 2000 -const worktreeCache = new Map() - -async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) { - const cached = worktreeCache.get(params.workspaceId) - const now = Date.now() - if (cached && cached.expiresAt > now) { - return cached - } - - const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger) - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger }) - const entry: WorktreeCacheEntry = { - expiresAt: now + WORKTREE_CACHE_TTL_MS, - repoRoot, - worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })), - } - worktreeCache.set(params.workspaceId, entry) - return entry -} - -async function resolveWorktreeDirectory(params: { - workspaceId: string - workspacePath: string - worktreeSlug: string - logger: Logger -}): Promise { - const { worktreeSlug } = params - const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger }) - const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug) - if (match) { - return match.directory - } - - // If the slug is new (e.g., created moments ago), refresh once. - worktreeCache.delete(params.workspaceId) - const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger }) - return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null -} - function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) { if (!uiDir) { app.log.warn("UI static directory not provided; API endpoints only") diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index de2afd22..9f2a68a2 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -1,6 +1,10 @@ import { FastifyInstance, FastifyReply } from "fastify" import { z } from "zod" import { WorkspaceManager } from "../../workspaces/manager" +import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status" +import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations" +import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees" +import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory" interface RouteDeps { workspaceManager: WorkspaceManager @@ -23,6 +27,20 @@ const WorkspaceFileContentBodySchema = z.object({ contents: z.string(), }) +const WorktreeGitDiffQuerySchema = z.object({ + path: z.string().trim().min(1, "Path is required"), + originalPath: z.string().trim().optional(), + scope: z.enum(["staged", "unstaged"]), +}) + +const WorktreeGitPathsBodySchema = z.object({ + paths: z.array(z.string().trim().min(1, "Path is required")).min(1, "At least one path is required"), +}) + +const WorktreeGitCommitBodySchema = z.object({ + message: z.string().trim().min(1, "Commit message is required"), +}) + const WorkspaceFileSearchQuerySchema = z.object({ q: z.string().trim().min(1, "Query is required"), limit: z.coerce.number().int().positive().max(200).optional(), @@ -118,10 +136,138 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { return handleWorkspaceError(error, reply) } }) + + app.get<{ + Params: { id: string; slug: string } + }>("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => { + try { + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + + return await getWorktreeGitStatus({ workspaceFolder: directory, logger: request.log }) + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) + + app.get<{ + Params: { id: string; slug: string } + Querystring: { path: string; originalPath?: string; scope: "staged" | "unstaged" } + }>("/api/workspaces/:id/worktrees/:slug/git-diff", async (request, reply) => { + try { + const query = WorktreeGitDiffQuerySchema.parse(request.query ?? {}) + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + + return await getWorktreeGitDiff({ + workspaceFolder: directory, + path: query.path, + originalPath: query.originalPath, + scope: query.scope, + }) + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) + + app.post<{ + Params: { id: string; slug: string } + Body: { paths: string[] } + }>("/api/workspaces/:id/worktrees/:slug/git-stage", async (request, reply) => { + try { + const body = WorktreeGitPathsBodySchema.parse(request.body ?? {}) + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + + await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths }) + return { ok: true as const } + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) + + app.post<{ + Params: { id: string; slug: string } + Body: { paths: string[] } + }>("/api/workspaces/:id/worktrees/:slug/git-unstage", async (request, reply) => { + try { + const body = WorktreeGitPathsBodySchema.parse(request.body ?? {}) + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + + await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths }) + return { ok: true as const } + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) + + app.post<{ + Params: { id: string; slug: string } + Body: { message: string } + }>("/api/workspaces/:id/worktrees/:slug/git-commit", async (request, reply) => { + try { + const body = WorktreeGitCommitBodySchema.parse(request.body ?? {}) + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + + const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message }) + return { ok: true as const, ...result } + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) +} + +async function resolveGitWorktreeDirectory( + workspaceManager: WorkspaceManager, + workspaceId: string, + worktreeSlug: string, + logger: { debug?: (obj: any, msg?: string) => void; warn?: (obj: any, msg?: string) => void }, + reply: FastifyReply, +): Promise { + const workspace = workspaceManager.get(workspaceId) + if (!workspace) { + reply.code(404) + reply.send({ error: "Workspace not found" }) + return null + } + + const gitAvailable = await isGitAvailable(workspace.path) + if (!gitAvailable) { + reply.code(503) + reply.send({ error: "Git is not installed or not available in PATH" }) + return null + } + + const { isGitRepo } = await resolveRepoRoot(workspace.path, logger) + if (!isGitRepo) { + reply.code(400) + reply.send({ error: "Workspace is not a Git repository" }) + return null + } + + const directory = await resolveWorktreeDirectory({ + workspaceId: workspace.id, + workspacePath: workspace.path, + worktreeSlug, + logger, + }) + if (!directory) { + reply.code(404) + reply.send({ error: "Worktree not found" }) + return null + } + + return directory } function handleWorkspaceError(error: unknown, reply: FastifyReply) { + if (isGitMutationError(error)) { + reply.code(error.statusCode) + return { error: error.message } + } if (error instanceof Error && error.message === "Workspace not found") { reply.code(404) return { error: "Workspace not found" } diff --git a/packages/server/src/workspaces/git-mutations.ts b/packages/server/src/workspaces/git-mutations.ts new file mode 100644 index 00000000..32c1ed53 --- /dev/null +++ b/packages/server/src/workspaces/git-mutations.ts @@ -0,0 +1,121 @@ +import { spawn } from "child_process" +import path from "path" + +type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string } + +class GitMutationError extends Error { + statusCode: number + + constructor(message: string, statusCode = 400) { + super(message) + this.name = "GitMutationError" + this.statusCode = statusCode + } +} + +function runGit(args: string[], cwd: string): Promise { + return new Promise((resolve) => { + const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "" + let stderr = "" + + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString() + }) + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString() + }) + child.once("error", (error) => { + resolve({ ok: false, error, stdout, stderr }) + }) + child.once("close", (code) => { + if (code === 0) { + resolve({ ok: true, stdout }) + } else { + const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`) + resolve({ ok: false, error, stdout, stderr }) + } + }) + }) +} + +export function normalizeGitWorktreeRelativePath(input: string): string { + const normalized = input.trim().replace(/\\+/g, "/").replace(/^\.\//, "") + if (!normalized) { + throw new GitMutationError("Path is required", 400) + } + if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) { + throw new GitMutationError(`Absolute paths are not allowed: ${input}`, 400) + } + if (normalized === "." || normalized === "..") { + throw new GitMutationError(`Invalid path: ${input}`, 400) + } + if (normalized.startsWith("../") || normalized.includes("/../") || normalized.endsWith("/..")) { + throw new GitMutationError(`Path traversal is not allowed: ${input}`, 400) + } + return normalized +} + +function normalizeGitMutationPaths(paths: string[]): string[] { + const deduped = new Set() + for (const rawPath of paths) { + deduped.add(normalizeGitWorktreeRelativePath(rawPath)) + } + const normalized = Array.from(deduped) + if (normalized.length === 0) { + throw new GitMutationError("At least one path is required", 400) + } + return normalized +} + +async function ensureGitCommandSucceeded(resultPromise: Promise, fallbackMessage: string): Promise { + const result = await resultPromise + if (!result.ok) { + const message = result.stderr?.trim() || result.error.message || fallbackMessage + throw new GitMutationError(message, 409) + } + return result.stdout +} + +export function isGitMutationError(error: unknown): error is GitMutationError { + return error instanceof GitMutationError +} + +export async function stageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise { + const paths = normalizeGitMutationPaths(params.paths) + await ensureGitCommandSucceeded(runGit(["add", "--", ...paths], params.workspaceFolder), "Failed to stage files") +} + +export async function unstageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise { + const paths = normalizeGitMutationPaths(params.paths) + const headResult = await runGit(["rev-parse", "--verify", "HEAD"], params.workspaceFolder) + if (headResult.ok) { + await ensureGitCommandSucceeded( + runGit(["restore", "--staged", "--", ...paths], params.workspaceFolder), + "Failed to unstage files", + ) + return + } + + await ensureGitCommandSucceeded( + runGit(["rm", "--cached", "--quiet", "--", ...paths], params.workspaceFolder), + "Failed to unstage files", + ) +} + +export async function commitWorktreeChanges(params: { workspaceFolder: string; message: string }): Promise<{ commitSha?: string }> { + const message = params.message.trim() + if (!message) { + throw new GitMutationError("Commit message is required", 400) + } + + await ensureGitCommandSucceeded(runGit(["commit", "-m", message], params.workspaceFolder), "Failed to create commit") + + const shaResult = await runGit(["rev-parse", "HEAD"], params.workspaceFolder) + if (!shaResult.ok) { + return {} + } + + const commitSha = shaResult.stdout.trim() + return commitSha ? { commitSha } : {} +} diff --git a/packages/server/src/workspaces/git-status.ts b/packages/server/src/workspaces/git-status.ts new file mode 100644 index 00000000..57d7cfb2 --- /dev/null +++ b/packages/server/src/workspaces/git-status.ts @@ -0,0 +1,385 @@ +import { spawn } from "child_process" +import { readFile } from "fs/promises" +import path from "path" + +import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, WorktreeGitStatusEntry } from "../api-types" +import type { LogLike } from "./git-worktrees" +import { normalizeGitWorktreeRelativePath } from "./git-mutations" + +type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string } +type GitSuccessResult = Extract + +async function readFileAsDiffText(filePath: string): Promise { + return readFile(filePath, "utf-8") +} + +async function readGitBlobAsDiffText(resultPromise: Promise, missingOk = false): Promise { + const result = await resultPromise + if (!result.ok) { + return decodeGitShowResult(result, missingOk) + } + return result.stdout +} + +function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0]): Promise { + return new Promise((resolve) => { + const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "" + let stderr = "" + + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString() + }) + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString() + }) + child.once("error", (error) => { + resolve({ ok: false, error, stdout, stderr }) + }) + child.once("close", (code) => { + if (acceptedExitCodes.includes(code ?? 0)) { + resolve({ ok: true, stdout }) + } else { + const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`) + resolve({ ok: false, error, stdout, stderr }) + } + }) + }) +} + +function ensureEntry(map: Map, path: string): WorktreeGitStatusEntry { + const existing = map.get(path) + if (existing) return existing + const next: WorktreeGitStatusEntry = { + path, + originalPath: null, + stagedStatus: null, + stagedAdditions: 0, + stagedDeletions: 0, + unstagedStatus: null, + unstagedAdditions: 0, + unstagedDeletions: 0, + } + map.set(path, next) + return next +} + +function normalizeGitStatusPath(value: string): string { + return value.trim().replace(/\\+/g, "/") +} + +function parseGitChangeKind(code: string): GitChangeKind | null { + const normalized = code.trim().toUpperCase() + if (!normalized) return null + if (normalized === "A") return "added" + if (normalized === "M") return "modified" + if (normalized === "D") return "deleted" + if (normalized.startsWith("R")) return "renamed" + if (normalized.startsWith("C")) return "copied" + if (normalized === "U") return "unmerged" + return null +} + +function applyNameStatusOutput( + map: Map, + output: string, + target: "stagedStatus" | "unstagedStatus", +) { + const tokens = output.split("\0") + let index = 0 + + while (index < tokens.length) { + const record = tokens[index++] ?? "" + if (!record) continue + + const parts = record.split("\t") + const statusCode = parseGitChangeKind(parts[0] ?? "") + if (!statusCode) continue + + const inlinePath = parts.slice(1).join("\t") + const firstPath = inlinePath || tokens[index++] || "" + const secondPath = statusCode === "renamed" || statusCode === "copied" ? tokens[index++] || "" : "" + const path = statusCode === "renamed" || statusCode === "copied" ? secondPath || firstPath : firstPath + const normalizedPath = normalizeGitStatusPath(path) + if (!normalizedPath) continue + const entry = ensureEntry(map, normalizedPath) + entry[target] = statusCode + if (statusCode === "renamed" || statusCode === "copied") { + const originalPath = normalizeGitStatusPath(firstPath) + entry.originalPath = originalPath || entry.originalPath || null + } + } +} + +function applyUntrackedOutput(map: Map, output: string) { + for (const rawLine of output.split(/\r?\n/)) { + const path = normalizeGitStatusPath(rawLine) + if (!path) continue + ensureEntry(map, path).unstagedStatus = "untracked" + } +} + +function parseSingleNumstat(output: string): { additions: number; deletions: number; isBinary: boolean; found: boolean } { + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line) continue + const parts = rawLine.split("\t") + const isBinary = parts[0] === "-" || parts[1] === "-" + return { + additions: isBinary ? 0 : Number.parseInt(parts[0] ?? "0", 10) || 0, + deletions: isBinary ? 0 : Number.parseInt(parts[1] ?? "0", 10) || 0, + isBinary, + found: true, + } + } + + return { additions: 0, deletions: 0, isBinary: false, found: false } +} + +async function getUntrackedFileNumstat(workspaceFolder: string, relativePath: string): Promise<{ additions: number; deletions: number }> { + const absolutePath = path.join(workspaceFolder, relativePath) + const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], workspaceFolder, [0, 1]) + if (!result.ok) { + throw result.error + } + + const parsed = parseSingleNumstat(result.stdout) + return { additions: parsed.additions, deletions: parsed.deletions } +} + +async function applyUntrackedFileStats(map: Map, workspaceFolder: string) { + const pending = Array.from(map.values()) + .filter((entry) => entry.unstagedStatus === "untracked") + .map(async (entry) => { + try { + const stats = await getUntrackedFileNumstat(workspaceFolder, entry.path) + entry.unstagedAdditions = stats.additions + entry.unstagedDeletions = stats.deletions + } catch { + entry.unstagedAdditions = 0 + entry.unstagedDeletions = 0 + } + }) + await Promise.all(pending) +} + +function applyNumstatOutput( + map: Map, + output: string, + target: "staged" | "unstaged", +) { + const tokens = output.split("\0") + let index = 0 + + while (index < tokens.length) { + const record = tokens[index++] ?? "" + if (!record) continue + + const parts = record.split("\t") + if (parts.length < 3) continue + + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] ?? "0", 10) + const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] ?? "0", 10) + const inlinePath = parts.slice(2).join("\t") + const isRenameLike = inlinePath === "" + const originalPath = isRenameLike ? normalizeGitStatusPath(tokens[index++] ?? "") : null + const normalizedPath = normalizeGitStatusPath(isRenameLike ? tokens[index++] ?? "" : inlinePath) + if (!normalizedPath) continue + + const entry = ensureEntry(map, normalizedPath) + if (originalPath) { + entry.originalPath = originalPath + } + + if (target === "staged") { + entry.stagedAdditions = Number.isFinite(additions) ? additions : 0 + entry.stagedDeletions = Number.isFinite(deletions) ? deletions : 0 + } else { + entry.unstagedAdditions = Number.isFinite(additions) ? additions : 0 + entry.unstagedDeletions = Number.isFinite(deletions) ? deletions : 0 + } + } +} + +export async function getWorktreeGitStatus(params: { + workspaceFolder: string + logger?: LogLike +}): Promise { + const { workspaceFolder, logger } = params + const [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult] = await Promise.all([ + runGit(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder), + runGit(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder), + runGit(["ls-files", "--others", "--exclude-standard"], workspaceFolder), + runGit(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder), + runGit(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder), + ]) + + for (const result of [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult]) { + if (!result.ok) { + logger?.warn?.({ workspaceFolder, err: result.error }, "Failed to read git status for worktree") + throw result.error + } + } + + const stagedOutput = (stagedResult as GitSuccessResult).stdout + const unstagedOutput = (unstagedResult as GitSuccessResult).stdout + const untrackedOutput = (untrackedResult as GitSuccessResult).stdout + const stagedNumstatOutput = (stagedNumstatResult as GitSuccessResult).stdout + const unstagedNumstatOutput = (unstagedNumstatResult as GitSuccessResult).stdout + + const entries = new Map() + applyNameStatusOutput(entries, stagedOutput, "stagedStatus") + applyNameStatusOutput(entries, unstagedOutput, "unstagedStatus") + applyUntrackedOutput(entries, untrackedOutput) + applyNumstatOutput(entries, stagedNumstatOutput, "staged") + applyNumstatOutput(entries, unstagedNumstatOutput, "unstaged") + await applyUntrackedFileStats(entries, workspaceFolder) + + return Array.from(entries.values()).sort((a, b) => a.path.localeCompare(b.path)) +} + +function decodeGitShowResult(result: GitResult, missingOk = false): string { + if (result.ok) return result.stdout + const message = result.stderr?.trim() || result.error.message || "" + if ( + missingOk && + (message.includes("exists on disk, but not in") || + message.includes("Path '") || + message.includes("does not exist") || + message.includes("unknown revision or path not in the working tree")) + ) { + return "" + } + throw result.error +} + +async function readGitIndexBlob(workspaceFolder: string, normalizedPath: string): Promise { + return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder) +} + +async function getTrackedDiffMetadata(params: { + workspaceFolder: string + scope: WorktreeGitDiffScope + normalizedPath: string + normalizedOriginalPath: string | null +}): Promise<{ isBinary: boolean; found: boolean }> { + const args = ["diff", "--numstat"] + if (params.scope === "staged") { + args.push("--cached") + } + args.push("--find-renames", "--find-copies", "--") + args.push(params.normalizedPath) + if (params.normalizedOriginalPath && params.normalizedOriginalPath !== params.normalizedPath) { + args.push(params.normalizedOriginalPath) + } + + const result = await runGit(args, params.workspaceFolder) + if (!result.ok) { + throw result.error + } + + const parsed = parseSingleNumstat(result.stdout) + return { isBinary: parsed.isBinary, found: parsed.found } +} + +async function getUntrackedDiffMetadata(params: { + workspaceFolder: string + normalizedPath: string +}): Promise<{ isBinary: boolean }> { + const absolutePath = path.join(params.workspaceFolder, params.normalizedPath) + const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1]) + if (!result.ok) { + throw result.error + } + + return { isBinary: parseSingleNumstat(result.stdout).isBinary } +} + +async function resolveUnstagedBeforePath(params: { + workspaceFolder: string + normalizedPath: string + normalizedOriginalPath: string | null +}): Promise { + const currentPathResult = await readGitIndexBlob(params.workspaceFolder, params.normalizedPath) + if (currentPathResult.ok || !params.normalizedOriginalPath || params.normalizedOriginalPath === params.normalizedPath) { + return currentPathResult + } + return readGitIndexBlob(params.workspaceFolder, params.normalizedOriginalPath) +} + +export async function getWorktreeGitDiff(params: { + workspaceFolder: string + path: string + originalPath?: string | null + scope: WorktreeGitDiffScope +}): Promise { + const normalizedPath = normalizeGitWorktreeRelativePath(params.path) + const normalizedOriginalPath = params.originalPath ? normalizeGitWorktreeRelativePath(params.originalPath) : null + + const trackedMetadata = await getTrackedDiffMetadata({ + workspaceFolder: params.workspaceFolder, + scope: params.scope, + normalizedPath, + normalizedOriginalPath, + }) + + const diffMetadata = + params.scope === "unstaged" && !trackedMetadata.found + ? await getUntrackedDiffMetadata({ + workspaceFolder: params.workspaceFolder, + normalizedPath, + }) + : trackedMetadata + + if (diffMetadata.isBinary) { + return { + path: normalizedPath, + originalPath: normalizedOriginalPath, + scope: params.scope, + before: "", + after: "", + isBinary: true, + } + } + + if (params.scope === "staged") { + const [beforeResult, afterResult] = await Promise.all([ + readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder), true), + readGitBlobAsDiffText(readGitIndexBlob(params.workspaceFolder, normalizedPath), true), + ]) + + return { + path: normalizedPath, + originalPath: normalizedOriginalPath, + scope: params.scope, + before: beforeResult, + after: afterResult, + isBinary: false, + } + } + + const indexResult = await resolveUnstagedBeforePath({ + workspaceFolder: params.workspaceFolder, + normalizedPath, + normalizedOriginalPath, + }) + + const beforeResult = await readGitBlobAsDiffText(Promise.resolve(indexResult), true) + let after = beforeResult + + const fsPath = path.join(params.workspaceFolder, normalizedPath) + try { + after = await readFileAsDiffText(fsPath) + } catch { + after = "" + } + + return { + path: normalizedPath, + originalPath: normalizedOriginalPath, + scope: params.scope, + before: beforeResult, + after, + isBinary: false, + } +} diff --git a/packages/server/src/workspaces/git-worktrees.ts b/packages/server/src/workspaces/git-worktrees.ts index f9b5998e..9e53e305 100644 --- a/packages/server/src/workspaces/git-worktrees.ts +++ b/packages/server/src/workspaces/git-worktrees.ts @@ -10,6 +10,10 @@ export interface LogLike { type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string } +function isGitUnavailableResult(result: GitResult): boolean { + return !result.ok && (result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT" +} + function runGit(args: string[], cwd: string): Promise { return new Promise((resolve) => { const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }) @@ -38,6 +42,9 @@ function runGit(args: string[], cwd: string): Promise { export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> { const result = await runGit(["rev-parse", "--show-toplevel"], folder) + if (isGitUnavailableResult(result)) { + throw new Error("Git is not installed or not available in PATH") + } if (!result.ok) { logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root") return { repoRoot: folder, isGitRepo: false } @@ -49,6 +56,11 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise return { repoRoot, isGitRepo: true } } +export async function isGitAvailable(folder: string): Promise { + const result = await runGit(["--version"], folder) + return result.ok || !isGitUnavailableResult(result) +} + function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> { const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = [] const lines = output.split(/\r?\n/) @@ -90,15 +102,22 @@ export async function listWorktrees(params: { logger?: LogLike }): Promise { const { repoRoot, workspaceFolder, logger } = params - const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" } const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder) if (!result.ok) { + const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" } logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only") return [rootDescriptor] } const records = parseWorktreePorcelain(result.stdout) + const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot)) + const rootDescriptor: WorktreeDescriptor = { + slug: "root", + directory: repoRoot, + kind: "root", + branch: rootRecord?.branch, + } const worktrees: WorktreeDescriptor[] = [rootDescriptor] const seen = new Set(["root"]) diff --git a/packages/server/src/workspaces/worktree-directory.ts b/packages/server/src/workspaces/worktree-directory.ts new file mode 100644 index 00000000..144aed98 --- /dev/null +++ b/packages/server/src/workspaces/worktree-directory.ts @@ -0,0 +1,99 @@ +import { realpath } from "fs/promises" +import type { LogLike } from "./git-worktrees" +import { listWorktrees, resolveRepoRoot } from "./git-worktrees" + +type WorktreeCacheEntry = { + expiresAt: number + repoRoot: string + worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string }> +} + +const WORKTREE_CACHE_TTL_MS = 2000 +const worktreeCache = new Map() + +async function normalizeDirectoryPath(directory: string): Promise { + const trimmed = (directory ?? "").trim() + if (!trimmed) return "" + try { + return await realpath(trimmed) + } catch { + return trimmed + } +} + +async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger?: LogLike }) { + const cached = worktreeCache.get(params.workspaceId) + const now = Date.now() + if (cached && cached.expiresAt > now) { + return cached + } + + const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger) + const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger }) + const entry: WorktreeCacheEntry = { + expiresAt: now + WORKTREE_CACHE_TTL_MS, + repoRoot, + worktrees: await Promise.all( + worktrees.map(async (wt) => ({ + slug: wt.slug, + directory: wt.directory, + normalizedDirectory: await normalizeDirectoryPath(wt.directory), + })), + ), + } + worktreeCache.set(params.workspaceId, entry) + return entry +} + +export async function resolveWorktreeDirectory(params: { + workspaceId: string + workspacePath: string + worktreeSlug: string + logger?: LogLike +}): Promise { + const cached = await getCachedWorktrees({ + workspaceId: params.workspaceId, + workspacePath: params.workspacePath, + logger: params.logger, + }) + const match = cached.worktrees.find((wt) => wt.slug === params.worktreeSlug) + if (match) { + return match.directory + } + + worktreeCache.delete(params.workspaceId) + const refreshed = await getCachedWorktrees({ + workspaceId: params.workspaceId, + workspacePath: params.workspacePath, + logger: params.logger, + }) + return refreshed.worktrees.find((wt) => wt.slug === params.worktreeSlug)?.directory ?? null +} + +export async function resolveWorktreeSlugForDirectory(params: { + workspaceId: string + workspacePath: string + directory: string + logger?: LogLike +}): Promise { + const target = await normalizeDirectoryPath(params.directory ?? "") + if (!target) return null + + const cached = await getCachedWorktrees({ + workspaceId: params.workspaceId, + workspacePath: params.workspacePath, + logger: params.logger, + }) + const match = cached.worktrees.find((wt) => wt.normalizedDirectory === target) + if (match) { + return match.slug + } + + worktreeCache.delete(params.workspaceId) + const refreshed = await getCachedWorktrees({ + workspaceId: params.workspaceId, + workspacePath: params.workspacePath, + logger: params.logger, + }) + return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null +} diff --git a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx index 6856fb24..3cf2204a 100644 --- a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { loadMonaco } from "../../lib/monaco/setup" import { getOrCreateTextModel } from "../../lib/monaco/model-cache" import { inferMonacoLanguageId } from "../../lib/monaco/language" @@ -15,6 +15,8 @@ interface MonacoDiffViewerProps { viewMode?: "split" | "unified" contextMode?: "expanded" | "collapsed" wordWrap?: "on" | "off" + onRequestInsertContext?: (selection: { startLine: number; endLine: number }) => void + insertContextLabel?: string } export function MonacoDiffViewer(props: MonacoDiffViewerProps) { @@ -24,6 +26,10 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { let diffEditor: any = null let monaco: any = null const [ready, setReady] = createSignal(false) + const [hoveredLine, setHoveredLine] = createSignal(null) + const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null) + const [widgetHovered, setWidgetHovered] = createSignal(false) + const [widgetPosition, setWidgetPosition] = createSignal<{ top: number; left: number } | null>(null) const resolvedContent = createMemo(() => { if (props.patch !== undefined && props.patch !== null) { @@ -49,6 +55,52 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { diffEditor = null } + const getModifiedEditor = () => diffEditor?.getModifiedEditor?.() ?? null + + const getActiveInsertRange = () => { + const selection = selectedRange() + if (selection) return selection + if (widgetHovered() && hoveredLine()) { + return { startLine: hoveredLine() as number, endLine: hoveredLine() as number } + } + const line = hoveredLine() + if (!line) return null + return { startLine: line, endLine: line } + } + + const layoutInsertWidget = () => { + const modifiedEditor = getModifiedEditor() + const container = host + if (!modifiedEditor || !container) return + const activeRange = getActiveInsertRange() + if (!activeRange) { + setWidgetPosition(null) + return + } + + try { + const modifiedDom = modifiedEditor.getDomNode?.() as HTMLElement | null + if (!modifiedDom) { + setWidgetPosition(null) + return + } + + const margin = modifiedDom.querySelector(".margin") + const scrollable = modifiedDom.querySelector(".monaco-scrollable-element.editor-scrollable") + const lineTop = modifiedEditor.getTopForLineNumber?.(activeRange.startLine) ?? 0 + const scrollTop = modifiedEditor.getScrollTop?.() ?? 0 + const lineHeight = Number(modifiedEditor.getOption?.(monaco.editor.EditorOption.lineHeight) ?? 18) + const modifiedRect = modifiedDom.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const seamLeft = modifiedRect.left - containerRect.left + (margin?.offsetWidth ?? scrollable?.offsetLeft ?? 0) + const centerTop = modifiedRect.top - containerRect.top + (lineTop - scrollTop) + lineHeight / 2 + + setWidgetPosition({ top: centerTop, left: seamLeft }) + } catch { + setWidgetPosition(null) + } + } + onMount(() => { let cancelled = false void (async () => { @@ -68,7 +120,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { renderWhitespace: "selection", fontSize: 13, wordWrap: props.wordWrap === "on" ? "on" : "off", - glyphMargin: false, + glyphMargin: true, folding: false, // Keep enough gutter space so unified diffs don't overlap `+`/`-` markers. lineNumbersMinChars: 4, @@ -81,6 +133,8 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { }) setReady(true) + + layoutInsertWidget() })() onCleanup(() => { @@ -95,6 +149,74 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") }) + createEffect(() => { + if (!ready() || !monaco || !diffEditor) return + const modifiedEditor = diffEditor.getModifiedEditor?.() + if (!modifiedEditor?.onDidChangeCursorSelection) return + + const disposable = modifiedEditor.onDidChangeCursorSelection((event: any) => { + const selection = event?.selection + if (!selection || selection.isEmpty?.()) { + setSelectedRange(null) + layoutInsertWidget() + return + } + setSelectedRange({ + startLine: Math.min(selection.startLineNumber, selection.endLineNumber), + endLine: Math.max(selection.startLineNumber, selection.endLineNumber), + }) + layoutInsertWidget() + }) + + onCleanup(() => { + try { + disposable?.dispose?.() + } catch { + // ignore + } + }) + }) + + createEffect(() => { + if (!ready() || !monaco || !diffEditor) return + const modifiedEditor = getModifiedEditor() + if (!modifiedEditor?.onMouseMove || !modifiedEditor?.onMouseLeave || !modifiedEditor?.onMouseDown) return + + const moveDisposable = modifiedEditor.onMouseMove((event: any) => { + const lineNumber = event?.target?.position?.lineNumber + setHoveredLine(typeof lineNumber === "number" ? lineNumber : null) + layoutInsertWidget() + }) + + const leaveDisposable = modifiedEditor.onMouseLeave(() => { + if (!widgetHovered()) { + setHoveredLine(null) + } + layoutInsertWidget() + }) + + const scrollDisposable = modifiedEditor.onDidScrollChange?.(() => { + layoutInsertWidget() + }) + + onCleanup(() => { + try { + moveDisposable?.dispose?.() + leaveDisposable?.dispose?.() + scrollDisposable?.dispose?.() + } catch { + // ignore + } + }) + }) + + createEffect(() => { + if (!ready() || !monaco || !diffEditor) return + const activeRange = getActiveInsertRange() + if (!activeRange) setWidgetPosition(null) + layoutInsertWidget() + }) + createEffect(() => { if (!ready() || !monaco || !diffEditor) return const viewMode = props.viewMode === "unified" ? "unified" : "split" @@ -145,5 +267,46 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { }) }) - return
+ return ( +
+
+ + {(position: () => { top: number; left: number }) => ( +
{ + setWidgetHovered(true) + layoutInsertWidget() + }} + onMouseLeave={() => { + setWidgetHovered(false) + layoutInsertWidget() + }} + > + +
+ )} +
+
+
+ ) } diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 694b9d36..0ead3c1a 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -43,6 +43,7 @@ import RightPanel from "./shell/right-panel/RightPanel" import { useDrawerChrome } from "./shell/useDrawerChrome" import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status" import { Maximize2, ShieldAlert } from "lucide-solid" +import type { PromptInputApi } from "../prompt-input/types" import type { LayoutMode } from "./shell/types" import { @@ -105,6 +106,7 @@ const InstanceShell2: Component = (props) => { const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) const [now, setNow] = createSignal(Date.now()) + const [sessionPromptApis, setSessionPromptApis] = createSignal>({}) // Worktree selector manages its own dialogs. const [showSessionSearch, setShowSessionSearch] = createSignal(false) @@ -268,6 +270,19 @@ const InstanceShell2: Component = (props) => { const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id)) + const activePromptInputApi = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") return null + return sessionPromptApis()[sessionId] ?? null + }) + + const registerSessionPromptApi = (sessionId: string, api: PromptInputApi | null) => { + setSessionPromptApis((current) => ({ + ...current, + [sessionId]: api, + })) + } + createEffect(() => { getPermissionAutoAcceptInFlightVersion() @@ -594,6 +609,7 @@ const InstanceShell2: Component = (props) => { onCloseRightDrawer={closeRightDrawer} onPinRightDrawer={pinRightDrawer} onUnpinRightDrawer={unpinRightDrawer} + promptInputApi={activePromptInputApi} setContentEl={setRightDrawerContentEl} /> @@ -656,6 +672,7 @@ const InstanceShell2: Component = (props) => { onCloseRightDrawer={closeRightDrawer} onPinRightDrawer={pinRightDrawer} onUnpinRightDrawer={unpinRightDrawer} + promptInputApi={activePromptInputApi} setContentEl={setRightDrawerContentEl} /> @@ -892,6 +909,7 @@ const InstanceShell2: Component = (props) => { escapeInDebounce={props.escapeInDebounce} isPhoneLayout={isPhoneLayout()} compactPromptLayout={compactPromptLayout()} + registerSessionPromptApi={registerSessionPromptApi} showSidebarToggle={showEmbeddedSidebarToggle()} onSidebarToggle={() => setLeftOpen(true)} forceCompactStatusLayout={showEmbeddedSidebarToggle()} diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index c031ac87..0a9ba0b7 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -10,7 +10,7 @@ import { type Component, } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" -import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client" +import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client" import IconButton from "@suid/material/IconButton" import MenuOpenIcon from "@suid/icons-material/MenuOpen" import PushPinIcon from "@suid/icons-material/PushPin" @@ -19,16 +19,23 @@ import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" import type { Instance } from "../../../../types/instance" import type { BackgroundProcess } from "../../../../../../server/src/api-types" import type { Session } from "../../../../types/session" +import type { PromptInputApi } from "../../../prompt-input/types" import type { DrawerViewState } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" -import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees" +import { + getDefaultWorktreeSlug, + getGitRepoStatus, + getOrCreateWorktreeClient, + getWorktreeSlugForSession, + getWorktrees, +} from "../../../../stores/worktrees" import { requestData } from "../../../../lib/opencode-api" import { serverApi } from "../../../../lib/api-client" import { showConfirmDialog } from "../../../../stores/alerts" import { showToastNotification } from "../../../../lib/notifications" -import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse" import { useGlobalPointerDrag } from "../useGlobalPointerDrag" +import { useGitChanges } from "./useGitChanges" import { RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, @@ -41,7 +48,11 @@ import { RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY, RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, + RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY, RIGHT_PANEL_TAB_STORAGE_KEY, readStoredBool, readStoredEnum, @@ -82,6 +93,7 @@ interface RightPanelProps { onCloseRightDrawer: () => void onPinRightDrawer: () => void onUnpinRightDrawer: () => void + promptInputApi: Accessor setContentEl: (el: HTMLElement | null) => void } @@ -133,6 +145,8 @@ const RightPanel: Component = (props) => { const [changesListTouched, setChangesListTouched] = createSignal(false) const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false) + const [gitStagedOpen, setGitStagedOpen] = createSignal(true) + const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true) const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone")) @@ -149,11 +163,28 @@ const RightPanel: Component = (props) => { return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY } + const gitSectionStorageKey = (section: "staged" | "unstaged") => { + const layout = listLayoutKey() + if (section === "staged") { + return layout === "phone" + ? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY + : RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY + } + return layout === "phone" + ? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY + : RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY + } + const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => { if (typeof window === "undefined") return window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false") } + const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => { + if (typeof window === "undefined") return + window.localStorage.setItem(gitSectionStorageKey(section), value ? "true" : "false") + } + createEffect(() => { // Refresh persisted visibility when layout changes (phone vs non-phone). const layout = listLayoutKey() @@ -185,6 +216,12 @@ const RightPanel: Component = (props) => { setGitChangesListOpen(true) setGitChangesListTouched(false) } + + const stagedPersisted = readStoredBool(gitSectionStorageKey("staged")) + setGitStagedOpen(stagedPersisted ?? true) + + const unstagedPersisted = readStoredBool(gitSectionStorageKey("unstaged")) + setGitUnstagedOpen(unstagedPersisted ?? true) }) createEffect(() => { @@ -339,34 +376,56 @@ const RightPanel: Component = (props) => { return getDefaultWorktreeSlug(props.instanceId) }) + const gitChangesWorktreeSlug = createMemo(() => { + if (getGitRepoStatus(props.instanceId) === false) return null + const slug = worktreeSlugForViewer().trim() + return slug ? slug : null + }) + + const gitChangesWorktree = createMemo(() => { + const slug = gitChangesWorktreeSlug() + if (!slug) return null + return getWorktrees(props.instanceId).find((worktree) => worktree.slug === slug) ?? null + }) + + const gitChangesBranchLabel = createMemo(() => { + const branch = gitChangesWorktree()?.branch?.trim() + return branch || null + }) + const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer())) - const [gitStatusEntries, setGitStatusEntries] = createSignal(null) - const [gitStatusLoading, setGitStatusLoading] = createSignal(false) - const [gitStatusError, setGitStatusError] = createSignal(null) - const [gitSelectedPath, setGitSelectedPath] = createSignal(null) - const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false) - const [gitSelectedError, setGitSelectedError] = createSignal(null) - const [gitSelectedBefore, setGitSelectedBefore] = createSignal(null) - const [gitSelectedAfter, setGitSelectedAfter] = createSignal(null) - - const gitMostChangedPath = createMemo(() => { - const entries = gitStatusEntries() - if (!Array.isArray(entries) || entries.length === 0) return null - const candidates = entries.filter((item) => item && item.status !== "deleted") - if (candidates.length === 0) return null - const best = candidates.reduce((currentBest, item) => { - const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0) - const score = (item?.added ?? 0) + (item?.removed ?? 0) - if (score > bestScore) return item - if (score < bestScore) return currentBest - return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest - }, candidates[0]) - return typeof best?.path === "string" ? best.path : null + const { + gitStatusEntries, + gitStatusLoading, + gitStatusError, + gitSelectedItemId, + gitBulkSelectedItemIds, + gitSelectedLoading, + gitSelectedError, + gitSelectedBefore, + gitSelectedAfter, + gitCommitMessage, + gitCommitSubmitting, + gitMostChangedItemId, + setGitCommitMessage, + handleGitRowClick, + refreshGitStatus, + insertGitChangeContext, + submitGitCommit, + stageGitFile, + unstageGitFile, + } = useGitChanges({ + t: props.t, + instanceId: props.instanceId, + rightPanelTab, + worktreeSlug: worktreeSlugForViewer, + isPhoneLayout: props.isPhoneLayout, + promptInputApi: props.promptInputApi, + closeGitList: () => setGitChangesListOpen(false), }) createEffect(() => { - // Reset tab state when worktree context changes. worktreeSlugForViewer() setBrowserPath(".") setBrowserEntries(null) @@ -375,111 +434,8 @@ const RightPanel: Component = (props) => { setBrowserSelectedContent(null) setBrowserSelectedError(null) setBrowserSelectedLoading(false) - - setGitStatusEntries(null) - setGitStatusError(null) - setGitStatusLoading(false) - setGitSelectedPath(null) - setGitSelectedLoading(false) - setGitSelectedError(null) - setGitSelectedBefore(null) - setGitSelectedAfter(null) }) - const loadGitStatus = async (force = false) => { - if (!force && gitStatusEntries() !== null) return - setGitStatusLoading(true) - setGitStatusError(null) - try { - const list = await requestData(browserClient().file.status(), "file.status") - setGitStatusEntries(Array.isArray(list) ? list : []) - } catch (error) { - setGitStatusError(error instanceof Error ? error.message : "Failed to load git status") - setGitStatusEntries([]) - } finally { - setGitStatusLoading(false) - } - } - - async function openGitFile(path: string) { - setGitSelectedPath(path) - setGitSelectedLoading(true) - setGitSelectedError(null) - setGitSelectedBefore(null) - setGitSelectedAfter(null) - - const list = gitStatusEntries() || [] - const entry = list.find((item) => item.path === path) || null - if (entry?.status === "deleted") { - setGitSelectedError("Deleted file diff is not available yet") - setGitSelectedLoading(false) - return - } - - // Phone: treat file selection as a commit action and close the overlay. - if (props.isPhoneLayout()) { - setGitChangesListOpen(false) - } - - try { - const content = await requestData(browserClient().file.read({ path }), "file.read") - const type = (content as any)?.type - const encoding = (content as any)?.encoding - if (type && type !== "text") { - throw new Error("Binary file cannot be displayed") - } - if (encoding === "base64") { - throw new Error("Binary file cannot be displayed") - } - const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null - if (afterText === null) { - throw new Error("Unsupported file type") - } - - setGitSelectedAfter(afterText) - - if (entry?.status === "added") { - setGitSelectedBefore("") - return - } - - const diffText = - typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0 - ? String((content as any).diff) - : (content as any)?.patch - ? buildUnifiedDiffFromSdkPatch((content as any).patch) - : "" - - const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText) - if (beforeText === null) { - throw new Error("Unable to calculate diff for this file") - } - setGitSelectedBefore(beforeText) - } catch (error) { - setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes") - } finally { - setGitSelectedLoading(false) - } - } - - createEffect(() => { - if (rightPanelTab() !== "git-changes") return - const entries = gitStatusEntries() - if (entries === null) return - if (gitSelectedPath()) return - const next = gitMostChangedPath() - if (!next) return - void openGitFile(next) - }) - - const refreshGitStatus = async () => { - await loadGitStatus(true) - const selected = gitSelectedPath() - if (selected) { - void openGitFile(selected) - } - } - const bestDiffFile = createMemo(() => { const diffs = props.activeSessionDiffs() if (!Array.isArray(diffs) || diffs.length === 0) return null @@ -680,21 +636,6 @@ const RightPanel: Component = (props) => { setBrowserSelectedDirty(false) }) - createEffect(() => { - if (rightPanelTab() !== "git-changes") return - if (gitStatusLoading()) return - if (gitStatusEntries() !== null) return - void loadGitStatus() - }) - - createEffect(() => { - if (rightPanelTab() === "git-changes") return - setGitSelectedBefore(null) - setGitSelectedAfter(null) - setGitSelectedLoading(false) - setGitSelectedError(null) - }) - const handleSelectChangesFile = (file: string, closeList: boolean) => { setSelectedFile(file) if (closeList) { @@ -911,12 +852,13 @@ const RightPanel: Component = (props) => { entries={gitStatusEntries} statusLoading={gitStatusLoading} statusError={gitStatusError} - selectedPath={gitSelectedPath} + selectedItemId={gitSelectedItemId} + selectedBulkItemIds={gitBulkSelectedItemIds} selectedLoading={gitSelectedLoading} selectedError={gitSelectedError} selectedBefore={gitSelectedBefore} selectedAfter={gitSelectedAfter} - mostChangedPath={gitMostChangedPath} + mostChangedItemId={gitMostChangedItemId} scopeKey={gitScopeKey} diffViewMode={diffViewMode} diffContextMode={diffContextMode} @@ -924,8 +866,28 @@ const RightPanel: Component = (props) => { onViewModeChange={setDiffViewMode} onContextModeChange={setDiffContextMode} onWordWrapModeChange={setDiffWordWrapMode} - onOpenFile={(path: string) => void openGitFile(path)} + onRowClick={handleGitRowClick} onRefresh={() => void refreshGitStatus()} + onInsertContext={insertGitChangeContext} + onStageFile={stageGitFile} + onUnstageFile={unstageGitFile} + commitMessage={gitCommitMessage} + commitSubmitting={gitCommitSubmitting} + onCommitMessageInput={setGitCommitMessage} + onSubmitCommit={() => void submitGitCommit()} + branchLabel={gitChangesBranchLabel} + stagedOpen={gitStagedOpen} + unstagedOpen={gitUnstagedOpen} + onToggleStagedOpen={() => { + const next = !gitStagedOpen() + setGitStagedOpen(next) + persistGitSectionOpen("staged", next) + }} + onToggleUnstagedOpen={() => { + const next = !gitUnstagedOpen() + setGitUnstagedOpen(next) + persistGitSectionOpen("unstaged", next) + }} listOpen={gitChangesListOpen} onToggleList={toggleGitList} splitWidth={gitChangesSplitWidth} diff --git a/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts new file mode 100644 index 00000000..a7e248b7 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts @@ -0,0 +1,148 @@ +import type { File as SdkGitFileStatus } from "@opencode-ai/sdk/v2/client" +import type { WorktreeGitStatusEntry } from "../../../../../../server/src/api-types" + +import type { GitChangeEntry, GitChangeListItem, GitChangeSection, GitChangeStatus } from "./types" + +function normalizeGitChangePath(path: unknown): string { + if (typeof path !== "string") return "" + const normalized = path.replace(/\\+/g, "/").replace(/^\.\//, "").trim() + return normalized +} + +export function normalizeGitChangeStatus(status: unknown): GitChangeStatus { + return typeof status === "string" && status.trim().length > 0 ? status : "modified" +} + +export function adaptSdkGitStatusEntry(entry: SdkGitFileStatus): GitChangeEntry { + return { + path: normalizeGitChangePath(entry?.path), + originalPath: null, + additions: typeof entry?.added === "number" ? entry.added : 0, + deletions: typeof entry?.removed === "number" ? entry.removed : 0, + status: normalizeGitChangeStatus(entry?.status), + } +} + +export function adaptSdkGitStatusEntries( + entries: SdkGitFileStatus[] | null | undefined, + details?: WorktreeGitStatusEntry[] | null, +): GitChangeEntry[] { + const detailsByPath = new Map( + (details ?? []) + .map((entry) => { + const path = normalizeGitChangePath(entry.path) + return path ? [{ ...entry, path }, path] : null + }) + .filter((entry): entry is [WorktreeGitStatusEntry, string] => Boolean(entry)) + .map(([entry, path]) => [path, entry] as const), + ) + const adaptedByPath = new Map() + + for (const entry of entries ?? []) { + const adapted = adaptSdkGitStatusEntry(entry) + if (!adapted.path) continue + const detail = detailsByPath.get(adapted.path) + adaptedByPath.set(adapted.path, { + ...adapted, + originalPath: detail?.originalPath ? normalizeGitChangePath(detail.originalPath) : adapted.originalPath ?? null, + stagedStatus: detail?.stagedStatus ?? null, + unstagedStatus: detail?.unstagedStatus ?? null, + stagedAdditions: detail?.stagedAdditions ?? 0, + stagedDeletions: detail?.stagedDeletions ?? 0, + unstagedAdditions: detail?.unstagedAdditions ?? 0, + unstagedDeletions: detail?.unstagedDeletions ?? 0, + }) + } + + for (const detail of details ?? []) { + const normalizedPath = normalizeGitChangePath(detail.path) + if (!normalizedPath || adaptedByPath.has(normalizedPath)) continue + adaptedByPath.set(normalizedPath, { + path: normalizedPath, + originalPath: detail.originalPath ? normalizeGitChangePath(detail.originalPath) : null, + additions: 0, + deletions: 0, + status: detail.unstagedStatus ?? detail.stagedStatus ?? "modified", + stagedStatus: detail.stagedStatus, + unstagedStatus: detail.unstagedStatus, + stagedAdditions: detail.stagedAdditions, + stagedDeletions: detail.stagedDeletions, + unstagedAdditions: detail.unstagedAdditions, + unstagedDeletions: detail.unstagedDeletions, + }) + } + + return Array.from(adaptedByPath.values()).filter((entry) => entry.path.length > 0) +} + +function buildGitChangeListItemId(section: GitChangeSection, path: string): string { + return `${section}:${path}` +} + +function splitGitChangePath(path: string) { + const normalized = normalizeGitChangePath(path) + const lastSlash = normalized.lastIndexOf("/") + if (lastSlash === -1) { + return { displayName: normalized, parentPath: "" } + } + return { + displayName: normalized.slice(lastSlash + 1), + parentPath: normalized.slice(0, lastSlash), + } +} + +export function buildGitChangeListItems(entries: GitChangeEntry[] | null | undefined): GitChangeListItem[] { + if (!Array.isArray(entries)) return [] + + const items: GitChangeListItem[] = [] + for (const entry of entries) { + const pathParts = splitGitChangePath(entry.path) + if (entry.stagedStatus) { + items.push({ + id: buildGitChangeListItemId("staged", entry.path), + path: entry.path, + originalPath: entry.originalPath ?? null, + section: "staged", + status: entry.stagedStatus, + additions: entry.stagedAdditions ?? 0, + deletions: entry.stagedDeletions ?? 0, + entry, + displayName: pathParts.displayName, + parentPath: pathParts.parentPath, + }) + } + if (entry.unstagedStatus) { + items.push({ + id: buildGitChangeListItemId("unstaged", entry.path), + path: entry.path, + originalPath: entry.originalPath ?? null, + section: "unstaged", + status: entry.unstagedStatus, + additions: entry.unstagedAdditions ?? entry.additions, + deletions: entry.unstagedDeletions ?? entry.deletions, + entry, + displayName: pathParts.displayName, + parentPath: pathParts.parentPath, + }) + } + if (!entry.stagedStatus && !entry.unstagedStatus) { + items.push({ + id: buildGitChangeListItemId("unstaged", entry.path), + path: entry.path, + originalPath: entry.originalPath ?? null, + section: "unstaged", + status: entry.status, + additions: entry.additions, + deletions: entry.deletions, + entry, + displayName: pathParts.displayName, + parentPath: pathParts.parentPath, + }) + } + } + + return items.sort((a, b) => { + if (a.section !== b.section) return a.section.localeCompare(b.section) + return a.path.localeCompare(b.path) + }) +} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx index 019dce1d..c7da139c 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx @@ -1,11 +1,20 @@ -import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js" -import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" +import { + For, + Show, + Suspense, + createMemo, + lazy, + type Accessor, + type Component, + type JSX, +} from "solid-js" -import { RefreshCw } from "lucide-solid" +import { ChevronDown, ChevronRight, GitBranch, RefreshCw } from "lucide-solid" import DiffToolbar from "../components/DiffToolbar" import SplitFilePanel from "../components/SplitFilePanel" -import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" +import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, GitChangeEntry, GitChangeListItem } from "../types" +import { buildGitChangeListItems } from "../git-changes-model" const LazyMonacoDiffViewer = lazy(() => import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })), @@ -16,16 +25,17 @@ interface GitChangesTabProps { activeSessionId: Accessor - entries: Accessor + entries: Accessor statusLoading: Accessor statusError: Accessor - selectedPath: Accessor + selectedItemId: Accessor + selectedBulkItemIds: Accessor> selectedLoading: Accessor selectedError: Accessor selectedBefore: Accessor selectedAfter: Accessor - mostChangedPath: Accessor + mostChangedItemId: Accessor scopeKey: Accessor @@ -36,8 +46,21 @@ interface GitChangesTabProps { onContextModeChange: (mode: DiffContextMode) => void onWordWrapModeChange: (mode: DiffWordWrapMode) => void - onOpenFile: (path: string) => void + onRowClick: (item: GitChangeListItem, event: MouseEvent) => void onRefresh: () => void + onInsertContext: (item: GitChangeListItem, selection: { startLine: number; endLine: number }) => void + onStageFile: (item: GitChangeListItem) => void + onUnstageFile: (item: GitChangeListItem) => void + commitMessage: Accessor + commitSubmitting: Accessor + onCommitMessageInput: (value: string) => void + onSubmitCommit: () => void + branchLabel: Accessor + + stagedOpen: Accessor + unstagedOpen: Accessor + onToggleStagedOpen: () => void + onToggleUnstagedOpen: () => void listOpen: Accessor onToggleList: () => void @@ -52,48 +75,54 @@ const GitChangesTab: Component = (props) => { const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info")) const entries = createMemo(() => (hasSession() ? props.entries() : null)) - const sorted = createMemo(() => { + const sorted = createMemo(() => { const list = entries() if (!Array.isArray(list)) return [] return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) }) + const listItems = createMemo(() => buildGitChangeListItems(sorted())) + const totals = createMemo(() => { - return sorted().reduce( + return listItems().reduce( (acc, item) => { - acc.additions += typeof item.added === "number" ? item.added : 0 - acc.deletions += typeof item.removed === "number" ? item.removed : 0 + acc.additions += typeof item.additions === "number" ? item.additions : 0 + acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 return acc }, { additions: 0, deletions: 0 }, ) }) + const stagedItems = createMemo(() => listItems().filter((item) => item.section === "staged")) + const unstagedItems = createMemo(() => listItems().filter((item) => item.section === "unstaged")) + const canCommit = createMemo(() => stagedItems().length > 0 && props.commitMessage().trim().length > 0 && !props.commitSubmitting()) - const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted")) - - const selectedEntry = createMemo(() => { - const list = sorted() - const selectedPath = props.selectedPath() - const fallbackPath = props.mostChangedPath() + const selectedEntry = createMemo(() => { + const list = listItems() + const selectedId = props.selectedItemId() + const fallbackId = props.mostChangedItemId() const found = - list.find((item) => item.path === selectedPath) || - (fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined) - return found ?? null + list.find((item) => item.id === selectedId) || + (fallbackId ? list.find((item) => item.id === fallbackId) : undefined) + return found?.entry ?? null }) const emptyViewerMessage = createMemo(() => { if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected") const currentEntries = entries() if (currentEntries === null) return props.t("instanceShell.gitChanges.loading") - if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty") + if (listItems().length === 0) return props.t("instanceShell.gitChanges.empty") return props.t("instanceShell.filesShell.viewerEmpty") }) + const binaryViewerActive = createMemo(() => props.selectedError() === props.t("instanceShell.gitChanges.binaryViewer")) + const renderContent = (): JSX.Element => { const totalsValue = totals() const selected = selectedEntry() - const sortedList = sorted() - const nonDeletedList = nonDeleted() + const allItems = listItems() + const stagedList = stagedItems() + const unstagedList = unstagedItems() const renderViewer = () => (
@@ -109,7 +138,7 @@ const GitChangesTab: Component = (props) => { selected && props.selectedBefore() !== null && props.selectedAfter() !== null && - selected.status !== "deleted" + true ? { path: selected.path, before: props.selectedBefore() as string, @@ -139,6 +168,14 @@ const GitChangesTab: Component = (props) => { viewMode={props.diffViewMode()} contextMode={props.diffContextMode()} wordWrap={props.diffWordWrapMode()} + insertContextLabel={props.t("instanceShell.gitChanges.actions.insertContext")} + onRequestInsertContext={binaryViewerActive() ? undefined : (selection) => { + const selectedId = props.selectedItemId() + if (!selectedId) return + const item = listItems().find((entry) => entry.id === selectedId) + if (!item) return + props.onInsertContext(item, selection) + }} /> )} @@ -163,66 +200,149 @@ const GitChangesTab: Component = (props) => { const renderEmptyList = () =>
{emptyViewerMessage()}
- const renderListPanel = () => ( - 0} fallback={renderEmptyList()}> - - {(item) => ( -
{ - 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()}> +
+
+ + +
+
+
+