Compare commits
8 Commits
main
...
v0.14.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e022a158eb | ||
|
|
9d9a6a79ec | ||
|
|
82a7c95dba | ||
|
|
313a0e579e | ||
|
|
a795869064 | ||
|
|
9bf4d351de | ||
|
|
657e78da6a | ||
|
|
dee356558f |
8
.github/workflows/build-and-upload.yml
vendored
8
.github/workflows/build-and-upload.yml
vendored
@@ -212,7 +212,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
@@ -313,7 +313,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
@@ -324,7 +324,9 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||
path: packages/electron-app/release/*.zip
|
||||
path: |
|
||||
packages/electron-app/release/*.zip
|
||||
packages/electron-app/release/*.AppImage
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
55
README.md
55
README.md
@@ -18,6 +18,7 @@ CodeNomad transforms OpenCode from a terminal tool into a **premium desktop work
|
||||
- **🎙️ Voice Input & Speech**
|
||||
- **🌳 Git Worktrees**
|
||||
- **💬 Rich Message Experience**
|
||||
- **🧩 SideCars**
|
||||
- **⌨️ Command Palette**
|
||||
- **📁 File System Browser**
|
||||
- **🔐 Authentication & Security**
|
||||
@@ -61,6 +62,60 @@ npx @neuralnomads/codenomad-dev --launch
|
||||
|
||||
---
|
||||
|
||||
## SideCars
|
||||
|
||||
SideCars let you open local web tools inside CodeNomad as tabs.
|
||||
|
||||
<details>
|
||||
<summary><strong>Configuration</strong></summary>
|
||||
|
||||
- **Name**: Display name used in CodeNomad
|
||||
- **Port**: Local HTTP or HTTPS service running on `127.0.0.1:<port>`
|
||||
- **Base path**: Mounted under `/sidecars/:id`
|
||||
- **Prefix mode**:
|
||||
- **Preserve prefix** forwards the full `/sidecars/:id/...` path upstream
|
||||
- **Strip prefix** removes `/sidecars/:id` before forwarding the request upstream
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>VSCode (OpenVSCode Server)</strong></summary>
|
||||
|
||||
Run with Docker:
|
||||
|
||||
```bash
|
||||
docker run -it --init -p 8000:3000 -v "${HOME}:${HOME}:cached" -e HOME=${HOME} gitpod/openvscode-server --server-base-path /sidecars/vscode
|
||||
```
|
||||
|
||||
Add SideCar as:
|
||||
|
||||
- **Name**: `VSCode`
|
||||
- **Port**: `http://127.0.0.1:8000`
|
||||
- **Base path**: `/sidecars/vscode`
|
||||
- **Prefix mode**: `Preserve prefix`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Terminal (ttyd)</strong></summary>
|
||||
|
||||
Run with:
|
||||
|
||||
```bash
|
||||
ttyd --writable zsh
|
||||
```
|
||||
|
||||
Add SideCar as:
|
||||
|
||||
- **Name**: `Terminal`
|
||||
- **Port**: `http://127.0.0.1:7681`
|
||||
- **Base path**: `/sidecars/terminal`
|
||||
- **Prefix mode**: `Strip prefix`
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
||||
|
||||
@@ -147,6 +147,13 @@
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
|
||||
@@ -81,6 +81,55 @@ export interface WorktreeMap {
|
||||
parentSessionWorktreeSlug: Record<string, string>
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<string, WorktreeCacheEntry>()
|
||||
|
||||
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<string | null> {
|
||||
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")
|
||||
|
||||
@@ -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<string | null> {
|
||||
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" }
|
||||
|
||||
121
packages/server/src/workspaces/git-mutations.ts
Normal file
121
packages/server/src/workspaces/git-mutations.ts
Normal file
@@ -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<GitResult> {
|
||||
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<string>()
|
||||
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<GitResult>, fallbackMessage: string): Promise<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 } : {}
|
||||
}
|
||||
385
packages/server/src/workspaces/git-status.ts
Normal file
385
packages/server/src/workspaces/git-status.ts
Normal file
@@ -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<GitResult, { ok: true }>
|
||||
|
||||
async function readFileAsDiffText(filePath: string): Promise<string> {
|
||||
return readFile(filePath, "utf-8")
|
||||
}
|
||||
|
||||
async function readGitBlobAsDiffText(resultPromise: Promise<GitResult>, missingOk = false): Promise<string> {
|
||||
const result = await resultPromise
|
||||
if (!result.ok) {
|
||||
return decodeGitShowResult(result, missingOk)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0]): Promise<GitResult> {
|
||||
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<string, WorktreeGitStatusEntry>, 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<string, WorktreeGitStatusEntry>,
|
||||
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<string, WorktreeGitStatusEntry>, 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<string, WorktreeGitStatusEntry>, 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<string, WorktreeGitStatusEntry>,
|
||||
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<WorktreeGitStatusEntry[]> {
|
||||
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<string, WorktreeGitStatusEntry>()
|
||||
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<GitResult> {
|
||||
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<GitResult> {
|
||||
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<WorktreeGitDiffResponse> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<GitResult> {
|
||||
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<GitResult> {
|
||||
|
||||
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<boolean> {
|
||||
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<WorktreeDescriptor[]> {
|
||||
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<string>(["root"])
|
||||
|
||||
99
packages/server/src/workspaces/worktree-directory.ts
Normal file
99
packages/server/src/workspaces/worktree-directory.ts
Normal file
@@ -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<string, WorktreeCacheEntry>()
|
||||
|
||||
async function normalizeDirectoryPath(directory: string): Promise<string> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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
|
||||
}
|
||||
@@ -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,55 @@ interface MonacoDiffViewerProps {
|
||||
viewMode?: "split" | "unified"
|
||||
contextMode?: "expanded" | "collapsed"
|
||||
wordWrap?: "on" | "off"
|
||||
onRequestInsertContext?: (selection: { startLine: number; endLine: number }) => void
|
||||
insertContextLabel?: string
|
||||
}
|
||||
|
||||
function getLineCount(value: string): number {
|
||||
if (!value) return 1
|
||||
return value.split("\n").length
|
||||
}
|
||||
|
||||
function getDigitCount(value: number): number {
|
||||
return String(Math.max(1, value)).length
|
||||
}
|
||||
|
||||
function getUnifiedGutterSizing(options: { before: string; after: string }) {
|
||||
const beforeLineCount = getLineCount(options.before)
|
||||
const afterLineCount = getLineCount(options.after)
|
||||
const beforeDigitCount = getDigitCount(beforeLineCount)
|
||||
const afterDigitCount = getDigitCount(afterLineCount)
|
||||
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
|
||||
const extraDigits = Math.max(0, maxDigitCount - 2)
|
||||
const beforeNumberChars = Math.max(2, beforeDigitCount)
|
||||
const afterNumberChars = Math.max(2, afterDigitCount)
|
||||
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)
|
||||
|
||||
return {
|
||||
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
|
||||
originalLineNumbersMinChars: beforeNumberChars,
|
||||
modifiedLineNumbersMinChars: afterNumberChars,
|
||||
lineDecorationsWidth: 6 + extraDigits * 2 + fourDigitPenalty * 2,
|
||||
}
|
||||
}
|
||||
|
||||
function getSplitGutterSizing(options: { before: string; after: string }) {
|
||||
const beforeLineCount = getLineCount(options.before)
|
||||
const afterLineCount = getLineCount(options.after)
|
||||
const beforeDigitCount = getDigitCount(beforeLineCount)
|
||||
const afterDigitCount = getDigitCount(afterLineCount)
|
||||
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
|
||||
const extraDigits = Math.max(0, maxDigitCount - 2)
|
||||
const beforeNumberChars = Math.max(2, beforeDigitCount)
|
||||
const afterNumberChars = Math.max(2, afterDigitCount)
|
||||
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)
|
||||
|
||||
return {
|
||||
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
|
||||
originalLineNumbersMinChars: beforeNumberChars,
|
||||
modifiedLineNumbersMinChars: afterNumberChars,
|
||||
lineDecorationsWidth: 8 + extraDigits * 2 + fourDigitPenalty,
|
||||
}
|
||||
}
|
||||
|
||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
@@ -23,7 +72,12 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
|
||||
let diffEditor: any = null
|
||||
let monaco: any = null
|
||||
let splitLayoutFrame: number | null = null
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [hoveredLine, setHoveredLine] = createSignal<number | null>(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 +103,90 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
diffEditor = null
|
||||
}
|
||||
|
||||
const clearSplitLayoutVariables = () => {
|
||||
if (!host) return
|
||||
host.style.removeProperty("--split-original-line-number-width")
|
||||
host.style.removeProperty("--split-original-delete-sign-left")
|
||||
host.style.removeProperty("--split-original-gutter-width")
|
||||
}
|
||||
|
||||
const syncSplitLayoutVariables = (options: {
|
||||
viewMode: "split" | "unified"
|
||||
originalLineNumbersMinChars: number
|
||||
lineDecorationsWidth: number
|
||||
}) => {
|
||||
if (!host) return
|
||||
if (splitLayoutFrame !== null && typeof window !== "undefined") {
|
||||
window.cancelAnimationFrame(splitLayoutFrame)
|
||||
splitLayoutFrame = null
|
||||
}
|
||||
if (options.viewMode !== "split" || typeof window === "undefined") {
|
||||
clearSplitLayoutVariables()
|
||||
return
|
||||
}
|
||||
|
||||
splitLayoutFrame = window.requestAnimationFrame(() => {
|
||||
splitLayoutFrame = null
|
||||
if (!host) return
|
||||
const originalLineNumbers = host.querySelector<HTMLElement>(".editor.original .line-numbers")
|
||||
const measuredWidth = originalLineNumbers?.getBoundingClientRect().width ?? 0
|
||||
const lineNumberWidth =
|
||||
measuredWidth > 0 ? measuredWidth : Math.max(12, options.originalLineNumbersMinChars * 6)
|
||||
host.style.setProperty("--split-original-line-number-width", `${lineNumberWidth}px`)
|
||||
host.style.setProperty("--split-original-delete-sign-left", `${lineNumberWidth}px`)
|
||||
host.style.setProperty(
|
||||
"--split-original-gutter-width",
|
||||
`${lineNumberWidth + options.lineDecorationsWidth}px`,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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<HTMLElement>(".margin")
|
||||
const scrollable = modifiedDom.querySelector<HTMLElement>(".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 () => {
|
||||
@@ -81,10 +219,17 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
})
|
||||
|
||||
setReady(true)
|
||||
|
||||
layoutInsertWidget()
|
||||
})()
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
if (splitLayoutFrame !== null && typeof window !== "undefined") {
|
||||
window.cancelAnimationFrame(splitLayoutFrame)
|
||||
splitLayoutFrame = null
|
||||
}
|
||||
clearSplitLayoutVariables()
|
||||
setReady(false)
|
||||
disposeEditor()
|
||||
})
|
||||
@@ -95,15 +240,101 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!host) return
|
||||
host.dataset.viewMode = props.viewMode === "split" ? "split" : "unified"
|
||||
})
|
||||
|
||||
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"
|
||||
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
||||
|
||||
const { before, after } = resolvedContent()
|
||||
const sizing =
|
||||
viewMode === "unified"
|
||||
? getUnifiedGutterSizing({ before, after })
|
||||
: getSplitGutterSizing({ before, after })
|
||||
const {
|
||||
diffEditorLineNumbersMinChars,
|
||||
originalLineNumbersMinChars,
|
||||
modifiedLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
} = sizing
|
||||
diffEditor.updateOptions({
|
||||
renderSideBySide: viewMode === "split",
|
||||
renderSideBySideInlineBreakpoint: 0,
|
||||
renderIndicators: true,
|
||||
lineNumbersMinChars: diffEditorLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
hideUnchangedRegions:
|
||||
contextMode === "collapsed"
|
||||
? { enabled: true }
|
||||
@@ -112,16 +343,30 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
})
|
||||
|
||||
try {
|
||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({
|
||||
wordWrap,
|
||||
lineNumbersMinChars: originalLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({
|
||||
wordWrap,
|
||||
lineNumbersMinChars: modifiedLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
syncSplitLayoutVariables({
|
||||
viewMode,
|
||||
originalLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -145,5 +390,46 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
})
|
||||
})
|
||||
|
||||
return <div class="monaco-viewer" ref={host} />
|
||||
return (
|
||||
<div class="monaco-viewer" ref={host}>
|
||||
<div class="git-change-context-overlay">
|
||||
<Show when={widgetPosition()}>
|
||||
{(position: () => { top: number; left: number }) => (
|
||||
<div
|
||||
class="git-change-context-widget-host"
|
||||
style={{ top: `${position().top}px`, left: `${position().left}px` }}
|
||||
onMouseEnter={() => {
|
||||
setWidgetHovered(true)
|
||||
layoutInsertWidget()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setWidgetHovered(false)
|
||||
layoutInsertWidget()
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="git-change-context-widget"
|
||||
aria-label={props.insertContextLabel ?? "Add git change context to prompt"}
|
||||
title={props.insertContextLabel ?? "Add git change context to prompt"}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const activeRange = getActiveInsertRange()
|
||||
if (!activeRange) return
|
||||
props.onRequestInsertContext?.(activeRange)
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<InstanceShellProps> = (props) => {
|
||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||
const [now, setNow] = createSignal(Date.now())
|
||||
const [sessionPromptApis, setSessionPromptApis] = createSignal<Record<string, PromptInputApi | null>>({})
|
||||
|
||||
// Worktree selector manages its own dialogs.
|
||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||
@@ -268,6 +270,19 @@ const InstanceShell2: Component<InstanceShellProps> = (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<InstanceShellProps> = (props) => {
|
||||
onCloseRightDrawer={closeRightDrawer}
|
||||
onPinRightDrawer={pinRightDrawer}
|
||||
onUnpinRightDrawer={unpinRightDrawer}
|
||||
promptInputApi={activePromptInputApi}
|
||||
setContentEl={setRightDrawerContentEl}
|
||||
/>
|
||||
</Box>
|
||||
@@ -656,6 +672,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onCloseRightDrawer={closeRightDrawer}
|
||||
onPinRightDrawer={pinRightDrawer}
|
||||
onUnpinRightDrawer={unpinRightDrawer}
|
||||
promptInputApi={activePromptInputApi}
|
||||
setContentEl={setRightDrawerContentEl}
|
||||
/>
|
||||
</Drawer>
|
||||
@@ -892,6 +909,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isPhoneLayout={isPhoneLayout()}
|
||||
compactPromptLayout={compactPromptLayout()}
|
||||
registerSessionPromptApi={registerSessionPromptApi}
|
||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||
onSidebarToggle={() => setLeftOpen(true)}
|
||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||
|
||||
@@ -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<PromptInputApi | null>
|
||||
|
||||
setContentEl: (el: HTMLElement | null) => void
|
||||
}
|
||||
@@ -133,6 +145,8 @@ const RightPanel: Component<RightPanelProps> = (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<RightPanelProps> = (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<RightPanelProps> = (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<RightPanelProps> = (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<GitFileStatus[] | null>(null)
|
||||
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
|
||||
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
|
||||
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
|
||||
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
|
||||
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
|
||||
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
|
||||
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
|
||||
|
||||
const gitMostChangedPath = createMemo<string | null>(() => {
|
||||
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<RightPanelProps> = (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<GitFileStatus[]>(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<FileContent>(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<string | null>(() => {
|
||||
const diffs = props.activeSessionDiffs()
|
||||
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
||||
@@ -680,21 +636,6 @@ const RightPanel: Component<RightPanelProps> = (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<RightPanelProps> = (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<RightPanelProps> = (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}
|
||||
|
||||
@@ -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<string, GitChangeEntry>()
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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<string | null>
|
||||
|
||||
entries: Accessor<GitFileStatus[] | null>
|
||||
entries: Accessor<GitChangeEntry[] | null>
|
||||
statusLoading: Accessor<boolean>
|
||||
statusError: Accessor<string | null>
|
||||
|
||||
selectedPath: Accessor<string | null>
|
||||
selectedItemId: Accessor<string | null>
|
||||
selectedBulkItemIds: Accessor<Set<string>>
|
||||
selectedLoading: Accessor<boolean>
|
||||
selectedError: Accessor<string | null>
|
||||
selectedBefore: Accessor<string | null>
|
||||
selectedAfter: Accessor<string | null>
|
||||
mostChangedPath: Accessor<string | null>
|
||||
mostChangedItemId: Accessor<string | null>
|
||||
|
||||
scopeKey: Accessor<string>
|
||||
|
||||
@@ -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<string>
|
||||
commitSubmitting: Accessor<boolean>
|
||||
onCommitMessageInput: (value: string) => void
|
||||
onSubmitCommit: () => void
|
||||
branchLabel: Accessor<string | null>
|
||||
|
||||
stagedOpen: Accessor<boolean>
|
||||
unstagedOpen: Accessor<boolean>
|
||||
onToggleStagedOpen: () => void
|
||||
onToggleUnstagedOpen: () => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
@@ -52,48 +75,54 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
||||
|
||||
const sorted = createMemo<GitFileStatus[]>(() => {
|
||||
const sorted = createMemo<GitChangeEntry[]>(() => {
|
||||
const list = entries()
|
||||
if (!Array.isArray(list)) return []
|
||||
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||
})
|
||||
|
||||
const listItems = createMemo<GitChangeListItem[]>(() => 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<GitFileStatus | null>(() => {
|
||||
const list = sorted()
|
||||
const selectedPath = props.selectedPath()
|
||||
const fallbackPath = props.mostChangedPath()
|
||||
const selectedEntry = createMemo<GitChangeEntry | null>(() => {
|
||||
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 = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
@@ -109,7 +138,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (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<GitChangesTabProps> = (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)
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
@@ -163,66 +200,149 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
|
||||
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||
|
||||
const renderListPanel = () => (
|
||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sortedList}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
onClick={() => {
|
||||
props.onOpenFile(item.path)
|
||||
}}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.path}>
|
||||
<span class="file-path-text">{item.path}</span>
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<Show when={item.status === "deleted"}>
|
||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||
</Show>
|
||||
<Show when={item.status !== "deleted"}>
|
||||
<>
|
||||
<span class="file-list-item-additions">+{item.added}</span>
|
||||
<span class="file-list-item-deletions">-{item.removed}</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
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 (
|
||||
<div
|
||||
class={`file-list-item git-change-list-item ${props.selectedItemId() === item.id ? "file-list-item-active" : ""} ${isBulkSelected() ? "git-change-list-item-bulk-selected" : ""}`}
|
||||
onMouseDown={(event) => {
|
||||
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
onClick={(event) => props.onRowClick(item, event)}
|
||||
title={item.path}
|
||||
>
|
||||
<div class="file-list-item-content" title={item.path}>
|
||||
<div class="file-list-item-path" title={item.path}>
|
||||
<span class="file-path-text">{item.path}</span>
|
||||
</div>
|
||||
<div class="git-change-list-item-right">
|
||||
<div class="file-list-item-stats">
|
||||
<span class="file-list-item-additions">+{item.additions}</span>
|
||||
<span class="file-list-item-deletions">-{item.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="git-change-list-item-actions-zone">
|
||||
<div class="git-change-list-item-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="git-change-row-action"
|
||||
title={actionLabel}
|
||||
aria-label={actionLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
triggerAction()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class={`git-change-row-action-glyph ${item.section === "staged" ? "git-change-row-action-glyph-minus" : "git-change-row-action-glyph-plus"}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="git-change-row-action-bar git-change-row-action-bar-horizontal" />
|
||||
<Show when={item.section !== "staged"}>
|
||||
<span class="git-change-row-action-bar git-change-row-action-bar-vertical" />
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSection = (
|
||||
title: string,
|
||||
items: GitChangeListItem[],
|
||||
isOpen: boolean,
|
||||
onToggle: () => void,
|
||||
) => (
|
||||
<div class="git-change-section">
|
||||
<button type="button" class="git-change-section-header" onClick={onToggle}>
|
||||
<span class="git-change-section-header-main">
|
||||
<span class="git-change-section-chevron">
|
||||
{isOpen ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span class="git-change-section-title">{title}</span>
|
||||
</span>
|
||||
<span class="git-change-section-count">{items.length}</span>
|
||||
</button>
|
||||
<Show when={isOpen}>
|
||||
<div class="git-change-section-items">
|
||||
<For each={items}>{(item) => renderListItem(item)}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderListOverlay = () => (
|
||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sortedList}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
onClick={() => props.onOpenFile(item.path)}
|
||||
title={item.path}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.path}>
|
||||
<span class="file-path-text">{item.path}</span>
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<Show when={item.status === "deleted"}>
|
||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||
</Show>
|
||||
<Show when={item.status !== "deleted"}>
|
||||
<>
|
||||
<span class="file-list-item-additions">+{item.added}</span>
|
||||
<span class="file-list-item-deletions">-{item.removed}</span>
|
||||
</>
|
||||
const renderGroupedList = () => (
|
||||
<Show when={allItems.length > 0} fallback={renderEmptyList()}>
|
||||
<div class="git-change-sections">
|
||||
<div class="git-change-section">
|
||||
<button type="button" class="git-change-section-header" onClick={props.onToggleStagedOpen}>
|
||||
<span class="git-change-section-header-main">
|
||||
<span class="git-change-section-chevron">
|
||||
{props.stagedOpen() ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span class="git-change-section-title-row">
|
||||
<span class="git-change-section-title">{props.t("instanceShell.gitChanges.sections.staged")}</span>
|
||||
<Show when={props.branchLabel()}>
|
||||
{(label) => (
|
||||
<span class="status-indicator session-status-list worktree-indicator git-change-section-badge" title={`Branch: ${label()}`}>
|
||||
<GitBranch class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<span class="worktree-indicator-label">{label()}</span>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span class="git-change-section-count">{stagedList.length}</span>
|
||||
</button>
|
||||
<Show when={props.stagedOpen()}>
|
||||
<div class="git-change-section-items">
|
||||
<div class="git-change-commit-box">
|
||||
<div class="git-change-commit-input-wrap">
|
||||
<textarea
|
||||
class="git-change-commit-input"
|
||||
value={props.commitMessage()}
|
||||
rows={1}
|
||||
placeholder={props.t("instanceShell.gitChanges.commit.placeholder")}
|
||||
onInput={(event) => props.onCommitMessageInput(event.currentTarget.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="git-change-commit-button git-change-commit-button-overlay"
|
||||
disabled={!canCommit()}
|
||||
onClick={() => props.onSubmitCommit()}
|
||||
>
|
||||
{props.commitSubmitting()
|
||||
? props.t("instanceShell.gitChanges.commit.submitting")
|
||||
: props.t("instanceShell.gitChanges.commit.submit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<For each={stagedList}>{(item) => renderListItem(item)}</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
{renderSection(
|
||||
props.t("instanceShell.gitChanges.sections.unstaged"),
|
||||
unstagedList,
|
||||
props.unstagedOpen(),
|
||||
props.onToggleUnstagedOpen,
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -264,9 +384,10 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||
/>
|
||||
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||
list={{ panel: renderGroupedList, overlay: renderGroupedList }}
|
||||
viewer={renderViewer()}
|
||||
listOpen={props.listOpen()}
|
||||
onToggleList={props.onToggleList}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, any>) => string
|
||||
instanceId: string
|
||||
rightPanelTab: Accessor<RightPanelTab>
|
||||
worktreeSlug: Accessor<string>
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
promptInputApi: Accessor<PromptInputApi | null>
|
||||
closeGitList: () => void
|
||||
}
|
||||
|
||||
export function useGitChanges(options: UseGitChangesOptions) {
|
||||
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitChangeEntry[] | null>(null)
|
||||
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
|
||||
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
|
||||
const [gitSelectedItemId, setGitSelectedItemId] = createSignal<string | null>(null)
|
||||
const [gitBulkSelectedItemIds, setGitBulkSelectedItemIds] = createSignal<Set<string>>(new Set())
|
||||
const [gitBulkSelectionAnchorId, setGitBulkSelectionAnchorId] = createSignal<string | null>(null)
|
||||
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
|
||||
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
|
||||
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
|
||||
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(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<string>()))
|
||||
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<string | null>(() => {
|
||||
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<string>()
|
||||
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<GitFileStatus[]>(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"),
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -576,113 +581,6 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
autoCapitalize="off"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="prompt-nav-buttons">
|
||||
<div class="prompt-nav-column prompt-nav-column-left">
|
||||
<Show when={showVoiceInput()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
beginVoicePress(event)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.preventDefault()
|
||||
endVoicePress()
|
||||
}}
|
||||
onPointerCancel={() => endVoicePress()}
|
||||
onLostPointerCapture={() => endVoicePress()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.repeat) return
|
||||
if (event.key !== " " && event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
beginVoicePress(event)
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key !== " " && event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
endVoicePress()
|
||||
}}
|
||||
onBlur={() => endVoicePress()}
|
||||
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
|
||||
aria-label={voiceInput.buttonTitle()}
|
||||
title={voiceInput.buttonTitle()}
|
||||
>
|
||||
<Show
|
||||
when={voiceInput.isRecording()}
|
||||
fallback={
|
||||
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
|
||||
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Mic class="h-4 w-4" aria-hidden="true" />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showConversationToggle()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
|
||||
onClick={() => toggleConversationMode(props.instanceId)}
|
||||
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
|
||||
aria-pressed={conversationModeEnabled()}
|
||||
aria-label={conversationModeButtonTitle()}
|
||||
title={conversationModeButtonTitle()}
|
||||
>
|
||||
<Volume2 class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-clear-button"
|
||||
onClick={handleClearPrompt}
|
||||
disabled={!canClearPrompt()}
|
||||
aria-label={t("promptInput.clear.ariaLabel")}
|
||||
title={t("promptInput.clear.title")}
|
||||
>
|
||||
<X class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="prompt-nav-column prompt-nav-column-right">
|
||||
<ExpandButton
|
||||
expandState={expandState}
|
||||
onToggleExpand={handleExpandToggle}
|
||||
/>
|
||||
<Show when={hasHistory()}>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectPreviousHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectNextHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={shouldShowOverlay()}>
|
||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||
<Show
|
||||
@@ -737,6 +635,116 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</div>
|
||||
|
||||
<div class="prompt-input-actions">
|
||||
<div class="prompt-nav-buttons">
|
||||
<div class="prompt-nav-column prompt-nav-column-left">
|
||||
<Show when={showVoiceInput()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
beginVoicePress(event)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.preventDefault()
|
||||
endVoicePress()
|
||||
}}
|
||||
onPointerCancel={() => endVoicePress()}
|
||||
onLostPointerCapture={() => endVoicePress()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.repeat) return
|
||||
if (event.key !== " " && event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
beginVoicePress(event)
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key !== " " && event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
endVoicePress()
|
||||
}}
|
||||
onBlur={() => endVoicePress()}
|
||||
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
|
||||
aria-label={voiceInput.buttonTitle()}
|
||||
title={voiceInput.buttonTitle()}
|
||||
>
|
||||
<Show
|
||||
when={voiceInput.isRecording()}
|
||||
fallback={
|
||||
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
|
||||
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Mic class="h-4 w-4" aria-hidden="true" />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showConversationToggle()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
|
||||
onClick={() => toggleConversationMode(props.instanceId)}
|
||||
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
|
||||
aria-pressed={conversationModeEnabled()}
|
||||
aria-label={conversationModeButtonTitle()}
|
||||
title={conversationModeButtonTitle()}
|
||||
>
|
||||
<Volume2 class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-clear-button"
|
||||
onClick={handleClearPrompt}
|
||||
disabled={!canClearPrompt()}
|
||||
aria-label={t("promptInput.clear.ariaLabel")}
|
||||
title={t("promptInput.clear.title")}
|
||||
>
|
||||
<X class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="prompt-nav-column prompt-nav-column-right">
|
||||
<ExpandButton
|
||||
expandState={expandState}
|
||||
onToggleExpand={handleExpandToggle}
|
||||
/>
|
||||
<Show when={hasHistory()}>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectPreviousHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectNextHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prompt-input-primary-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="stop-button"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,7 @@ interface SessionViewProps {
|
||||
onSidebarToggle?: () => void
|
||||
forceCompactStatusLayout?: boolean
|
||||
isActive?: boolean
|
||||
registerSessionPromptApi?: (sessionId: string, api: PromptInputApi | null) => void
|
||||
}
|
||||
|
||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
@@ -149,6 +150,7 @@ export const SessionView: Component<SessionViewProps> = (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<SessionViewProps> = (props) => {
|
||||
return () => {
|
||||
if (promptInputApi === api) {
|
||||
promptInputApi = null
|
||||
props.registerSessionPromptApi?.(props.sessionId, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||
|
||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
|
||||
const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128
|
||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
|
||||
@@ -174,6 +173,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
let lastResetKey: string | number | undefined
|
||||
let suppressAutoScrollOnce = false
|
||||
let pendingInitialScroll = true
|
||||
let lastObservedScrollOffset = 0
|
||||
let lastObservedPinnedAtBottom = false
|
||||
|
||||
const state: VirtualFollowListState = {
|
||||
autoScroll,
|
||||
@@ -239,15 +240,29 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
if (!handle || !element) return
|
||||
|
||||
const offset = handle.scrollOffset
|
||||
const scrolledUp = offset < lastObservedScrollOffset - 1
|
||||
const wasPinnedAtBottom = lastObservedPinnedAtBottom
|
||||
const scrollHeight = handle.scrollSize
|
||||
const clientHeight = element.clientHeight
|
||||
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||
const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||
lastObservedScrollOffset = offset
|
||||
|
||||
const hasItems = props.items().length > 0
|
||||
setShowScrollBottomButton(hasItems && !atBottom)
|
||||
setShowScrollTopButton(hasItems && !atTop)
|
||||
|
||||
// Keyboard/PageUp scrolls can move the viewport without ever hitting our
|
||||
// local key intent listeners (for example after dragging the native
|
||||
// scrollbar). If follow mode stays enabled, the next render notification
|
||||
// snaps the list straight back to bottom. A real upward viewport move away
|
||||
// from bottom should always break follow unless a hold target is active.
|
||||
if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && heldItemCount() === null) {
|
||||
setAutoScroll(false)
|
||||
lastObservedPinnedAtBottom = false
|
||||
return
|
||||
}
|
||||
|
||||
// Sync autoScroll state based on scroll position if it was a user scroll
|
||||
if (hasUserScrollIntent()) {
|
||||
if (atBottom && heldItemCount() !== null) {
|
||||
@@ -259,6 +274,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
|
||||
lastObservedPinnedAtBottom = autoScroll() && atBottom
|
||||
}
|
||||
|
||||
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
||||
@@ -357,11 +374,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
const relativeTop = targetRect.top - containerRect.top
|
||||
const exceedsViewport = targetRect.height > element.clientHeight
|
||||
|
||||
if (
|
||||
exceedsViewport &&
|
||||
relativeTop <= holdTargetTopThresholdPx() &&
|
||||
relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX
|
||||
) {
|
||||
if (exceedsViewport && relativeTop < 0) {
|
||||
const alignDelta = relativeTop - holdTargetTopThresholdPx()
|
||||
if (Math.abs(alignDelta) > 1) {
|
||||
element.scrollTop = Math.max(0, element.scrollTop + alignDelta)
|
||||
}
|
||||
setHeldItemCount(itemCount)
|
||||
}
|
||||
}
|
||||
@@ -395,6 +412,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
createEffect(on(() => props.resetKey?.(), () => {
|
||||
itemElements.clear()
|
||||
setHeldItemCount(null)
|
||||
lastObservedScrollOffset = 0
|
||||
lastObservedPinnedAtBottom = false
|
||||
}))
|
||||
|
||||
// Handle autoScroll (Follow) on items change
|
||||
|
||||
@@ -26,6 +26,14 @@ type WorktreeOption =
|
||||
| { kind: "action"; key: "__create__"; label: string }
|
||||
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
|
||||
|
||||
type DeleteErrorKind = "localChanges" | "inUse" | "notFound" | "permissionDenied" | "unknown"
|
||||
|
||||
type DeleteErrorDetails = {
|
||||
summary: string
|
||||
causeLabel: string
|
||||
nextStep: string
|
||||
}
|
||||
|
||||
function preventSelectPress(event: PointerEvent | MouseEvent) {
|
||||
// Prevent Select.Item from treating this as a selection.
|
||||
// We intentionally prevent default to stop Kobalte's internal press handling.
|
||||
@@ -64,6 +72,57 @@ function relativePath(fromDir: string, toDir: string): string {
|
||||
return relParts.join("/") || "."
|
||||
}
|
||||
|
||||
function extractDeleteErrorMessage(input: string): string {
|
||||
const trimmed = (input ?? "").trim()
|
||||
if (!trimmed) return ""
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as { error?: unknown }
|
||||
if (typeof parsed?.error === "string" && parsed.error.trim()) {
|
||||
return parsed.error.trim()
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the raw string when the backend returned plain text.
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function classifyDeleteError(message: string): DeleteErrorKind {
|
||||
const normalized = message.toLowerCase()
|
||||
|
||||
if (
|
||||
normalized.includes("modified or untracked files") ||
|
||||
normalized.includes("contains modified") ||
|
||||
normalized.includes("contains untracked") ||
|
||||
normalized.includes("use --force to delete it")
|
||||
) {
|
||||
return "localChanges"
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("in use") ||
|
||||
normalized.includes("resource busy") ||
|
||||
normalized.includes("device or resource busy") ||
|
||||
normalized.includes("ebusy") ||
|
||||
normalized.includes("file is being used") ||
|
||||
normalized.includes("process cannot access the file") ||
|
||||
normalized.includes("directory not empty")
|
||||
) {
|
||||
return "inUse"
|
||||
}
|
||||
|
||||
if (normalized.includes("not found") || normalized.includes("no such file") || normalized.includes("cannot find")) {
|
||||
return "notFound"
|
||||
}
|
||||
|
||||
if (normalized.includes("permission denied") || normalized.includes("access is denied") || normalized.includes("eperm")) {
|
||||
return "permissionDenied"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
interface WorktreeSelectorProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
@@ -80,6 +139,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
const [deleteTarget, setDeleteTarget] = createSignal<WorktreeOption & { kind: "worktree" } | null>(null)
|
||||
const [forceDelete, setForceDelete] = createSignal(false)
|
||||
const [isDeleting, setIsDeleting] = createSignal(false)
|
||||
const [deleteError, setDeleteError] = createSignal<string | null>(null)
|
||||
|
||||
const session = createMemo(() => sessions().get(props.instanceId)?.get(props.sessionId))
|
||||
const isChildSession = createMemo(() => Boolean(session()?.parentId))
|
||||
@@ -114,10 +174,16 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => {
|
||||
if (opt.slug === "root") return
|
||||
setForceDelete(false)
|
||||
setDeleteError(null)
|
||||
setDeleteTarget(opt)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
setDeleteOpen(false)
|
||||
setDeleteError(null)
|
||||
}
|
||||
|
||||
const repoRoot = createMemo(() => {
|
||||
const list = getWorktrees(props.instanceId)
|
||||
return list.find((wt) => wt.slug === "root")?.directory ?? ""
|
||||
@@ -139,6 +205,89 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeDeleteError = (input: string) => {
|
||||
let sanitized = (input ?? "").trim()
|
||||
if (!sanitized) {
|
||||
return t("instanceShell.worktree.delete.error.fallback")
|
||||
}
|
||||
|
||||
sanitized = sanitized.replace(/[A-Za-z]:[\\/][^\r\n"']+/g, "[path]")
|
||||
sanitized = sanitized.replace(/\\Users\\[^\\/\r\n]+/gi, "\\Users\\[user]")
|
||||
sanitized = sanitized.replace(/\/Users\/[^/\r\n]+/g, "/Users/[user]")
|
||||
sanitized = sanitized.replace(/\/home\/[^/\r\n]+/g, "/home/[user]")
|
||||
sanitized = sanitized.replace(/([A-Za-z]:[\\/])?Users[\\/][^\\/\r\n]+/gi, "$1Users/[user]")
|
||||
return sanitized
|
||||
}
|
||||
|
||||
const handleCopyDeleteError = async (mode: "raw" | "sanitized") => {
|
||||
const raw = deleteError()
|
||||
if (!raw) return
|
||||
const text = mode === "sanitized" ? sanitizeDeleteError(raw) : raw
|
||||
|
||||
try {
|
||||
const ok = await copyToClipboard(text)
|
||||
showToastNotification({
|
||||
message: ok
|
||||
? t(mode === "sanitized" ? "instanceShell.worktree.delete.error.copySanitizedSuccess" : "instanceShell.worktree.delete.error.copySuccess")
|
||||
: t("instanceShell.worktree.delete.error.copyFailure"),
|
||||
variant: ok ? "success" : "error",
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("Failed to copy delete worktree error", error)
|
||||
showToastNotification({
|
||||
message: t("instanceShell.worktree.delete.error.copyFailure"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteErrorDetails = createMemo<DeleteErrorDetails | null>(() => {
|
||||
const raw = deleteError()
|
||||
if (!raw) return null
|
||||
|
||||
const parsed = extractDeleteErrorMessage(raw)
|
||||
const kind = classifyDeleteError(parsed)
|
||||
|
||||
switch (kind) {
|
||||
case "localChanges":
|
||||
return {
|
||||
summary: t("instanceShell.worktree.delete.error.summary.localChanges"),
|
||||
causeLabel: t("instanceShell.worktree.delete.error.cause.localChanges"),
|
||||
nextStep: t("instanceShell.worktree.delete.error.nextStep.localChanges"),
|
||||
}
|
||||
case "inUse":
|
||||
return {
|
||||
summary: t("instanceShell.worktree.delete.error.summary.inUse"),
|
||||
causeLabel: t("instanceShell.worktree.delete.error.cause.inUse"),
|
||||
nextStep: t("instanceShell.worktree.delete.error.nextStep.inUse"),
|
||||
}
|
||||
case "notFound":
|
||||
return {
|
||||
summary: t("instanceShell.worktree.delete.error.summary.notFound"),
|
||||
causeLabel: t("instanceShell.worktree.delete.error.cause.notFound"),
|
||||
nextStep: t("instanceShell.worktree.delete.error.nextStep.notFound"),
|
||||
}
|
||||
case "permissionDenied":
|
||||
return {
|
||||
summary: t("instanceShell.worktree.delete.error.summary.permissionDenied"),
|
||||
causeLabel: t("instanceShell.worktree.delete.error.cause.permissionDenied"),
|
||||
nextStep: t("instanceShell.worktree.delete.error.nextStep.permissionDenied"),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
summary: t("instanceShell.worktree.delete.error.summary.unknown"),
|
||||
causeLabel: t("instanceShell.worktree.delete.error.cause.unknown"),
|
||||
nextStep: t("instanceShell.worktree.delete.error.nextStep.unknown"),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const displayDeleteError = createMemo(() => {
|
||||
const raw = deleteError()
|
||||
if (!raw) return null
|
||||
return extractDeleteErrorMessage(raw)
|
||||
})
|
||||
|
||||
const handleChange = async (value: WorktreeOption | null) => {
|
||||
if (worktreesUnavailable()) return
|
||||
if (!value) return
|
||||
@@ -343,22 +492,23 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && setDeleteOpen(false)}>
|
||||
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && closeDeleteDialog()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-3 md:p-4">
|
||||
<Dialog.Content class="modal-surface w-[clamp(640px,45vw,960px)] max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)] overflow-y-auto p-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">Removes the git worktree checkout directory for this branch.</Dialog.Description>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1">Deletes this branch worktree and its local folder.</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<Show when={deleteTarget()}>
|
||||
{(target) => (
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{target().slug}</p>
|
||||
<p class="text-[11px] text-secondary mt-2 break-all font-mono">{target().directory}</p>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary px-3 py-2">
|
||||
<p class="text-sm text-primary">
|
||||
Worktree <span class="font-semibold font-mono">"{target().slug}"</span>
|
||||
</p>
|
||||
<p class="text-[11px] text-secondary break-all font-mono leading-5">{target().directory}</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
@@ -377,7 +527,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
onClick={closeDeleteDialog}
|
||||
disabled={isDeleting()}
|
||||
>
|
||||
Cancel
|
||||
@@ -389,12 +539,13 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
onClick={() => {
|
||||
const target = deleteTarget()
|
||||
if (!target) {
|
||||
setDeleteOpen(false)
|
||||
closeDeleteDialog()
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
setIsDeleting(true)
|
||||
setDeleteError(null)
|
||||
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
|
||||
await reloadWorktrees(props.instanceId)
|
||||
await reloadWorktreeMap(props.instanceId)
|
||||
@@ -403,15 +554,12 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")
|
||||
}
|
||||
|
||||
setDeleteOpen(false)
|
||||
closeDeleteDialog()
|
||||
showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" })
|
||||
})()
|
||||
.catch((error) => {
|
||||
log.warn("Failed to delete worktree", error)
|
||||
showToastNotification({
|
||||
message: error instanceof Error ? error.message : "Failed to delete worktree",
|
||||
variant: "error",
|
||||
})
|
||||
setDeleteError(error instanceof Error ? error.message : t("instanceShell.worktree.delete.error.fallback"))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDeleting(false)
|
||||
@@ -421,6 +569,56 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
{isDeleting() ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={displayDeleteError()}>
|
||||
{(message) => (
|
||||
<div class="rounded-lg border border-danger bg-danger/10 p-3 flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs font-medium text-danger uppercase tracking-wide">
|
||||
{t("instanceShell.worktree.delete.error.title")}
|
||||
</p>
|
||||
<Show when={deleteErrorDetails()}>
|
||||
{(details) => (
|
||||
<>
|
||||
<p class="text-sm text-primary font-medium">{details().summary}</p>
|
||||
<p class="text-sm text-secondary">
|
||||
<span class="font-medium text-primary">{t("instanceShell.worktree.delete.error.causeLabel")}</span>{" "}
|
||||
{details().causeLabel}
|
||||
</p>
|
||||
<p class="text-sm text-secondary">
|
||||
<span class="font-medium text-primary">{t("instanceShell.worktree.delete.error.nextStepLabel")}</span>{" "}
|
||||
{details().nextStep}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<pre class="max-h-[40vh] overflow-auto whitespace-pre-wrap break-all rounded border border-danger/30 bg-surface-primary px-3 py-2 text-xs text-primary select-text leading-5">{message()}</pre>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => {
|
||||
void handleCopyDeleteError("raw")
|
||||
}}
|
||||
>
|
||||
{t("instanceShell.worktree.delete.error.copyRaw")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => {
|
||||
void handleCopyDeleteError("sanitized")
|
||||
}}
|
||||
>
|
||||
{t("instanceShell.worktree.delete.error.copySanitized")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
httpLogger.info(message)
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response): Promise<string> {
|
||||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
||||
const headers = normalizeHeaders(init?.headers)
|
||||
@@ -112,7 +138,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<Response> {
|
||||
|
||||
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<WorktreeGitStatusResponse> {
|
||||
return request<WorktreeGitStatusResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-status`,
|
||||
)
|
||||
},
|
||||
fetchWorktreeGitDiff(id: string, slug: string, requestPayload: WorktreeGitDiffRequest): Promise<WorktreeGitDiffResponse> {
|
||||
const params = new URLSearchParams({ path: requestPayload.path, scope: requestPayload.scope })
|
||||
if (requestPayload.originalPath) {
|
||||
params.set("originalPath", requestPayload.originalPath)
|
||||
}
|
||||
return request<WorktreeGitDiffResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-diff?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
stageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise<WorktreeGitMutationResponse> {
|
||||
return request<WorktreeGitMutationResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-stage`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
},
|
||||
unstageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise<WorktreeGitMutationResponse> {
|
||||
return request<WorktreeGitMutationResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-unstage`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
},
|
||||
commitWorktreeGitChanges(id: string, slug: string, payload: WorktreeGitCommitRequest): Promise<WorktreeGitCommitResponse> {
|
||||
return request<WorktreeGitCommitResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-commit`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
||||
|
||||
@@ -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",
|
||||
@@ -147,6 +158,30 @@ export const instanceMessages = {
|
||||
"instanceShell.diff.enableWordWrap": "Enable word wrap",
|
||||
"instanceShell.diff.disableWordWrap": "Disable word wrap",
|
||||
"instanceShell.worktree.create": "+ Create worktree",
|
||||
"instanceShell.worktree.delete.error.title": "Delete failed",
|
||||
"instanceShell.worktree.delete.error.fallback": "Failed to delete worktree",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "Likely cause:",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "Suggested next step:",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "Git refused to delete this worktree because it has modified or untracked files.",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad could not delete this worktree because something is still using files in the directory.",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad could not delete this worktree because the directory or worktree record was not found.",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad could not delete this worktree because access to the directory was denied.",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad could not delete this worktree.",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "Local changes",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "Another process is using this worktree",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "The worktree directory or record is missing",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "Insufficient filesystem permissions",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "The backend returned an unclassified delete error",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "Enable Force delete if you want to discard local changes, or clean the worktree and try again.",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "Close terminals, editors, watchers, or background processes using this worktree and try again.",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "Refresh worktrees and try again. If it still fails, inspect the worktree path on disk.",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Check filesystem permissions and close applications that may be locking this directory, then try again.",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "Review the raw error below for details, then retry after addressing the reported problem.",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "Copy error",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "Copy sanitized",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "Copied delete error",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Copied sanitized delete error",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "Failed to copy delete error",
|
||||
|
||||
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
||||
"instanceShell.plan.empty": "Nothing planned yet.",
|
||||
|
||||
@@ -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",
|
||||
@@ -155,6 +166,30 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.actions.output": "Salida",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "Detener",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "Terminar",
|
||||
"instanceShell.worktree.delete.error.title": "Error al eliminar",
|
||||
"instanceShell.worktree.delete.error.fallback": "Error al eliminar el worktree",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "Causa probable:",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "Siguiente paso sugerido:",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "Git rechazo la eliminacion de este worktree porque contiene archivos modificados o sin seguimiento.",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad no pudo eliminar este worktree porque algo sigue usando archivos dentro del directorio.",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad no pudo eliminar este worktree porque no se encontro el directorio o el registro del worktree.",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad no pudo eliminar este worktree porque se denego el acceso al directorio.",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad no pudo eliminar este worktree.",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "Cambios locales",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "Otro proceso esta usando este worktree",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "Falta el directorio o el registro del worktree",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "Permisos insuficientes del sistema de archivos",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "El backend devolvio un error de eliminacion sin clasificar",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "Activa Forzar eliminacion si quieres descartar los cambios locales, o limpia el worktree e intentalo de nuevo.",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "Cierra terminales, editores, observadores o procesos en segundo plano que usen este worktree y vuelve a intentarlo.",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "Recarga los worktrees y vuelve a intentarlo. Si sigue fallando, inspecciona la ruta del worktree en disco.",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Revisa los permisos del sistema de archivos y cierra aplicaciones que puedan estar bloqueando este directorio, luego vuelve a intentarlo.",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "Revisa el error sin procesar de abajo para ver los detalles y vuelve a intentarlo despues de corregir el problema indicado.",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "Copiar error",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "Copiar saneado",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "Error de eliminacion copiado",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Error de eliminacion saneado copiado",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "No se pudo copiar el error de eliminacion",
|
||||
|
||||
"versionPill.appWithVersion": "App {version}",
|
||||
"versionPill.ui": "UI",
|
||||
|
||||
@@ -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",
|
||||
@@ -155,6 +166,30 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.actions.output": "Sortie",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "Arrêter",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "Terminer",
|
||||
"instanceShell.worktree.delete.error.title": "Echec de suppression",
|
||||
"instanceShell.worktree.delete.error.fallback": "Impossible de supprimer le worktree",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "Cause probable :",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "Etape suivante suggeree :",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "Git a refuse de supprimer ce worktree car il contient des fichiers modifies ou non suivis.",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad n'a pas pu supprimer ce worktree car quelque chose utilise encore des fichiers dans ce dossier.",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad n'a pas pu supprimer ce worktree car le dossier ou l'enregistrement du worktree est introuvable.",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad n'a pas pu supprimer ce worktree car l'acces au dossier a ete refuse.",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad n'a pas pu supprimer ce worktree.",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "Modifications locales",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "Un autre processus utilise ce worktree",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "Le dossier ou l'enregistrement du worktree est manquant",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "Permissions du systeme de fichiers insuffisantes",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "Le backend a renvoye une erreur de suppression non classee",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "Activez la suppression forcee si vous voulez jeter les modifications locales, ou nettoyez le worktree puis reessayez.",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "Fermez les terminaux, editeurs, observateurs ou processus en arrière-plan qui utilisent ce worktree puis reessayez.",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "Rechargez les worktrees puis reessayez. Si cela echoue encore, inspectez le chemin du worktree sur le disque.",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Verifiez les permissions du systeme de fichiers et fermez les applications qui peuvent verrouiller ce dossier, puis reessayez.",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "Consultez l'erreur brute ci-dessous pour les details, puis reessayez apres avoir corrige le probleme signale.",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "Copier l'erreur",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "Copier la version nettoyee",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "Erreur de suppression copiee",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Erreur de suppression nettoyee copiee",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "Impossible de copier l'erreur de suppression",
|
||||
|
||||
"versionPill.appWithVersion": "Appli {version}",
|
||||
"versionPill.ui": "UI",
|
||||
|
||||
@@ -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": "עבור לתצוגה מפוצלת",
|
||||
@@ -163,6 +174,30 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.actions.output": "פלט",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "עצור",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "סיים",
|
||||
"instanceShell.worktree.delete.error.title": "המחיקה נכשלה",
|
||||
"instanceShell.worktree.delete.error.fallback": "מחיקת ה-worktree נכשלה",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "סיבה סבירה:",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "השלב הבא המומלץ:",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "Git סירב למחוק את ה-worktree הזה כי יש בו קבצים ששונו או קבצים לא במעקב.",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad לא הצליח למחוק את ה-worktree הזה כי משהו עדיין משתמש בקבצים שבתיקייה.",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad לא הצליח למחוק את ה-worktree הזה כי התיקייה או רשומת ה-worktree לא נמצאו.",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad לא הצליח למחוק את ה-worktree הזה כי הגישה לתיקייה נדחתה.",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad לא הצליח למחוק את ה-worktree הזה.",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "שינויים מקומיים",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "תהליך אחר משתמש ב-worktree הזה",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "תיקיית ה-worktree או הרשומה שלו חסרות",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "אין הרשאות מתאימות במערכת הקבצים",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "ה-backend החזיר שגיאת מחיקה שלא סווגה",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "הפעילו מחיקה בכפייה אם אתם רוצים לזרוק את השינויים המקומיים, או נקו את ה-worktree ונסו שוב.",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "סגרו טרמינלים, עורכים, watchers או תהליכי רקע שמשתמשים ב-worktree הזה ונסו שוב.",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "רעננו את רשימת ה-worktrees ונסו שוב. אם זה עדיין נכשל, בדקו את נתיב ה-worktree על הדיסק.",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "בדקו את הרשאות מערכת הקבצים וסגרו אפליקציות שעשויות לנעול את התיקייה הזאת, ואז נסו שוב.",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "עיינו בשגיאה הגולמית למטה לפרטים, ואז נסו שוב אחרי טיפול בבעיה שדווחה.",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "העתק שגיאה",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "העתק גרסה מסוננת",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "שגיאת המחיקה הועתקה",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "שגיאת המחיקה המסוננת הועתקה",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "העתקת שגיאת המחיקה נכשלה",
|
||||
|
||||
"versionPill.appWithVersion": "אפליקציה {version}",
|
||||
"versionPill.ui": "ממשק",
|
||||
|
||||
@@ -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": "ファイルを選択",
|
||||
@@ -155,6 +166,30 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.actions.output": "出力",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "停止",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "終了",
|
||||
"instanceShell.worktree.delete.error.title": "削除に失敗しました",
|
||||
"instanceShell.worktree.delete.error.fallback": "worktree の削除に失敗しました",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "考えられる原因:",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "推奨される次の手順:",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "この worktree に変更済みまたは未追跡のファイルがあるため、Git が削除を拒否しました。",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "このディレクトリ内のファイルがまだ使用中のため、CodeNomad はこの worktree を削除できませんでした。",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "ディレクトリまたは worktree レコードが見つからなかったため、CodeNomad はこの worktree を削除できませんでした。",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "ディレクトリへのアクセスが拒否されたため、CodeNomad はこの worktree を削除できませんでした。",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad はこの worktree を削除できませんでした。",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "ローカル変更",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "別のプロセスがこの worktree を使用中です",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "worktree のディレクトリまたは記録が見つかりません",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "ファイルシステム権限が不足しています",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "バックエンドが分類できない削除エラーを返しました",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "ローカル変更を破棄したい場合は Force delete を有効にするか、worktree を整理してから再試行してください。",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "この worktree を使用している端末、エディタ、watcher、バックグラウンドプロセスを閉じてから再試行してください。",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "worktree 一覧を更新して再試行してください。まだ失敗する場合は、ディスク上の worktree パスを確認してください。",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "ファイルシステム権限を確認し、このディレクトリをロックしている可能性のあるアプリを閉じてから再試行してください。",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "下の生エラーで詳細を確認し、報告された問題に対処してから再試行してください。",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "エラーをコピー",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "サニタイズ済みをコピー",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "削除エラーをコピーしました",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "サニタイズ済みの削除エラーをコピーしました",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "削除エラーをコピーできませんでした",
|
||||
|
||||
"versionPill.appWithVersion": "アプリ {version}",
|
||||
"versionPill.ui": "UI",
|
||||
|
||||
@@ -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": "Выбрать файл",
|
||||
@@ -155,6 +166,30 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.actions.output": "Вывод",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "Остановить",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "Завершить",
|
||||
"instanceShell.worktree.delete.error.title": "Удаление не удалось",
|
||||
"instanceShell.worktree.delete.error.fallback": "Не удалось удалить worktree",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "Вероятная причина:",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "Рекомендуемый следующий шаг:",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "Git отказался удалять этот worktree, потому что в нем есть измененные или неотслеживаемые файлы.",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad не смог удалить этот worktree, потому что что-то все еще использует файлы в каталоге.",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad не смог удалить этот worktree, потому что каталог или запись worktree не найдены.",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad не смог удалить этот worktree, потому что доступ к каталогу был запрещен.",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad не смог удалить этот worktree.",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "Локальные изменения",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "Другой процесс использует этот worktree",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "Каталог или запись worktree отсутствуют",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "Недостаточно прав файловой системы",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "Бэкенд вернул неклассифицированную ошибку удаления",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "Включите принудительное удаление, если хотите отбросить локальные изменения, либо очистите worktree и попробуйте снова.",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "Закройте терминалы, редакторы, watcher-процессы или фоновые процессы, использующие этот worktree, и попробуйте снова.",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "Обновите список worktree и попробуйте снова. Если ошибка сохранится, проверьте путь worktree на диске.",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Проверьте права файловой системы и закройте приложения, которые могут удерживать этот каталог, затем попробуйте снова.",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "Посмотрите необработанную ошибку ниже, затем попробуйте снова после устранения указанной проблемы.",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "Копировать ошибку",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "Копировать обезличенную",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "Ошибка удаления скопирована",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Обезличенная ошибка удаления скопирована",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "Не удалось скопировать ошибку удаления",
|
||||
|
||||
"versionPill.appWithVersion": "Приложение {version}",
|
||||
"versionPill.ui": "UI",
|
||||
|
||||
@@ -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": "选择文件",
|
||||
@@ -155,6 +166,30 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.actions.output": "输出",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "停止",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "终止",
|
||||
"instanceShell.worktree.delete.error.title": "删除失败",
|
||||
"instanceShell.worktree.delete.error.fallback": "删除 worktree 失败",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "可能原因:",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "建议的下一步:",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "Git 拒绝删除这个 worktree,因为其中包含已修改或未跟踪的文件。",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad 无法删除这个 worktree,因为目录中的文件仍在被某些进程使用。",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad 无法删除这个 worktree,因为目录或 worktree 记录未找到。",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad 无法删除这个 worktree,因为目录访问被拒绝。",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad 无法删除这个 worktree。",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "本地更改",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "另一个进程正在使用这个 worktree",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "worktree 目录或记录缺失",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "文件系统权限不足",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "后端返回了未分类的删除错误",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "如果你想丢弃本地更改,请启用强制删除,或者先清理 worktree 后再重试。",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "关闭正在使用这个 worktree 的终端、编辑器、watcher 或后台进程,然后再试一次。",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "刷新 worktree 列表后再试一次。如果仍然失败,请检查磁盘上的 worktree 路径。",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "检查文件系统权限,并关闭可能锁定此目录的应用程序,然后再试一次。",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "查看下方原始错误详情,并在处理提示的问题后再次重试。",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "复制错误",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "复制脱敏内容",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "已复制删除错误",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "已复制脱敏后的删除错误",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "复制删除错误失败",
|
||||
|
||||
"versionPill.appWithVersion": "应用 {version}",
|
||||
"versionPill.ui": "UI",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
.prompt-input-wrapper {
|
||||
@apply grid items-stretch;
|
||||
grid-template-columns: minmax(0, 1fr) 64px;
|
||||
grid-template-columns: minmax(0, 1fr) 72px 64px;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -19,6 +19,16 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.prompt-input-primary-actions {
|
||||
@apply flex flex-col items-center;
|
||||
align-self: stretch;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-inline-start: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.prompt-input-field-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -37,7 +47,7 @@
|
||||
.prompt-input {
|
||||
@apply w-full pt-2.5 border text-sm resize-none outline-none transition-colors;
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-end: 7.5rem;
|
||||
padding-inline-end: 0.75rem;
|
||||
font-family: inherit;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
@@ -85,16 +95,12 @@
|
||||
|
||||
/* Navigation buttons container (expand, prev, next). */
|
||||
.prompt-nav-buttons {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
inset-inline-end: 0.25rem;
|
||||
bottom: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
justify-content: center;
|
||||
gap: 0.125rem;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prompt-nav-column {
|
||||
@@ -287,7 +293,6 @@
|
||||
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.send-button.shell-mode {
|
||||
@@ -421,7 +426,7 @@
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.prompt-input-wrapper {
|
||||
grid-template-columns: minmax(0, 1fr) 40px;
|
||||
grid-template-columns: minmax(0, 1fr) 64px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,7 +434,6 @@
|
||||
.prompt-input {
|
||||
min-height: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-inline-end: 7.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,48 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
direction: ltr;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.git-change-context-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="unified"] .line-numbers {
|
||||
text-align: left !important;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .line-numbers,
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.modified .line-numbers {
|
||||
text-align: left !important;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .glyph-margin {
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .line-numbers {
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .cldr.delete-sign {
|
||||
left: var(--split-original-delete-sign-left, 14px) !important;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .margin,
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .margin-view-zones,
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .margin-view-overlays {
|
||||
width: var(--split-original-gutter-width, 24px) !important;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .editor-scrollable {
|
||||
left: var(--split-original-gutter-width, 24px) !important;
|
||||
width: calc(100% - var(--split-original-gutter-width, 24px)) !important;
|
||||
}
|
||||
|
||||
.file-viewer-empty {
|
||||
@@ -507,6 +815,7 @@
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
|
||||
Reference in New Issue
Block a user