mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-20 11:16:06 +00:00
feat(files): unify Workspace, Changes and Commits with central previews (#797)
## Summary Unify the right-panel Git and file surfaces as **Files**, with a compact **Workspace / Changes / Commits** switch and a shared central preview above the conversation composer. - Workspace: lazy, keyboard-accessible directory tree; read-only source, Markdown and image previews; bounded binary-file fallback. - Changes: indexed/working-tree files and central diffs, with existing Git mutations kept in a collapsed actions section. - Commits: paginated history of the browsed worktree, commit details/files and first-parent diffs, including root commits, renames, deletions and gitlinks. - Preserve drafts and mode navigation; fence late responses across session and worktree changes. Migrate previous tab customization and retain diff/wrap preferences. Bundle Monaco tokenizers locally for offline highlighting. - Worktree browsing does not move a conversation or check out a branch. ## Implementation The common Solid UI is used by Electron and Tauri. New file previews reuse the directory ownership boundary and bounded filesystem reader. Historical reads use worker Git commands with full-hash validation, authoritative file membership, bounded command output/time and snapshot-based paging. The old sidebar editor and split viewer are retired. See `dev-docs/FILES_PANEL.md`. ## Validation Before rebasing onto current dev: - Server, UI and Electron typechecks passed. - Server/UI and Electron builds passed. - 20 focused unit/route tests passed, including real isolated root and linked repositories, preview bounds and directory ownership. - 12 distinct browser scenarios passed: 5 Files flows (including real SessionView/composer, Markdown/images/source and RTL keyboard navigation) and 7 existing Git lifecycle regressions. Post-rebase validation, independent gatekeeper reviews and the Windows Tauri test build will be recorded below as they complete. This PR is intended for interactive validation before merge.
This commit is contained in:
parent
77692a739c
commit
ee5f7382d7
70 changed files with 3804 additions and 1802 deletions
|
|
@ -39,6 +39,12 @@
|
|||
|
||||
## Coding Principles
|
||||
|
||||
- Workspace text remains editable in the central file reader (including Markdown source), with Save and Ctrl/Cmd+S. Keep dirty drafts in `components/workspace-file-editor.ts`, scoped by instance/directory/path; invalidation must not replace them. Saves reread for external-change confirmation and pass the exact owned directory through the deletion-fenced filesystem write route. Images, binary content and historical diffs remain read-only.
|
||||
|
||||
- Files row selection never opens the central preview implicitly. All three modes reuse `FileRowActions` with a pinned eye using the shared `icon-toggle` active state; secondary actions use whole-row overflow measurements. Keep selection beneath the entire row including controls and rollover in `styles/panels/control-hover.css`. Changes keeps staged/unstaged disclosures with commit actions at the top of the staged section. Git image readers use exact bounded HEAD/index/worktree or parent/commit bytes, not current-file substitutes for historical content.
|
||||
|
||||
- The Files panel combines Workspace, Changes and Commits in one compact switch, initially on Workspace. Its lazy worktree tree preserves expanded folders while switching modes; tree styles live in `styles/panels/workspace-tree.css`, Git navigation in `styles/panels/git-history.css`, row actions in `styles/panels/file-row-actions.css`. Workspace secondary actions are folder-specific (open, terminal, copy path) and file-specific (open, reveal, copy path). Changes rows carry explicit stage/unstage actions and drag between staged/unstaged drop zones. File/diff readers close with a trailing X; the unified/split switch shows the current layout with Columns/Rows iconography. All three modes are cache-first: directory listings, git status and commit history revalidate lazily on filesystem invalidation, mode activation or explicit refresh, never on every focus or expand. `components/files-preview-view.tsx` chooses the central editable Workspace reader or shared read-only diff reader above the composer. Worktree browsing never checks out a branch or moves a session. Historical reads use bounded worker Git commands behind existing worktree ownership checks. Workspace previews use the bounded directory-authorized preview route; Monaco tokenizers ship locally. See `dev-docs/FILES_PANEL.md` for lifecycle, migration and validation.
|
||||
|
||||
- Tauri's Tao Windows input backport lives in `packages/tauri-app/vendor/`; preserve upstream provenance and avoid message pumping under input mutexes. Run `node scripts/test-tauri-input-deadlock.mjs --baseline` on Windows when changing it. See `dev-docs/TAURI_WINDOWS_INPUT_DEADLOCK.md` for the captured failure and override removal criteria.
|
||||
|
||||
- Verified shared npm OpenCode installations at 2.0.15+ delegate version changes to native `upgrade` through `opencode-update/native-upgrade.ts`, with bundled npm scoped to the verified prefix. Native Windows image retention replaces the write preflight only on that path; first install, older migration and same-version repair retain direct npm/preflight. Never retry a failed native mutation via npm or restart the daemon implicitly. Validate with the isolated `scripts/test-opencode-upgrade-native.mjs` fixture.
|
||||
|
|
|
|||
104
dev-docs/FILES_PANEL.md
Normal file
104
dev-docs/FILES_PANEL.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# Files panel
|
||||
|
||||
The right panel has one Files entry with three compact modes:
|
||||
|
||||
- **Workspace** (initial default): lazy directory tree for the browsed worktree.
|
||||
- **Changes**: indexed and working-tree changes, including new/deleted files.
|
||||
- **Commits**: paginated history, commit message and changed-file list.
|
||||
|
||||
The last selected mode and existing diff/wrap preferences use client layout
|
||||
storage. Migration merges the old Git and Files tab positions and visibility
|
||||
once, retaining access when either entry was previously visible. Each mode
|
||||
retains its mounted navigation while switching between them. Tree snapshots are
|
||||
scoped to the physical worktree directory. All three modes are cache-first:
|
||||
listings, git status and history serve cached data and revalidate lazily on
|
||||
filesystem invalidation, mode activation (when absent or marked stale) or
|
||||
explicit refresh. Reopening a folder re-reads its visible subtree only when the
|
||||
filesystem changed since it was loaded; collapsed folders are never re-read on
|
||||
expand. Workspace rows expose a hover/focus `…` menu (folder: open, terminal,
|
||||
copy path; file: open, reveal, copy path). Changes rows carry an explicit
|
||||
per-file stage/unstage button and accept drag-and-drop between the staged and
|
||||
unstaged sections. File row clicks select without opening the reader. Workspace,
|
||||
Changes and commit-file rows share `FileRowActions`: a pinned eye toggles the
|
||||
central preview with the shared `icon-toggle` active state. Secondary actions
|
||||
stay inline while the measured whole row fits, falling back to the shared menu
|
||||
only on overflow; an open menu survives resizing until dismissal. Hover uses
|
||||
`control-hover.css`'s shared surface overlay and selection covers the whole row.
|
||||
Change counts are muted; additions use success green and deletions error red.
|
||||
|
||||
Changes retains two independently collapsible sections, initially expanded:
|
||||
Staged Changes and Changes. Git actions and the compact commit-message field
|
||||
live at the top of Staged Changes, before its file rows, without an additional
|
||||
Actions Git disclosure at the bottom. Collapsing either section preserves the
|
||||
commit draft and selection, causes no status read, and leaves its header as a
|
||||
drop target for staging/unstaging even when the file list is hidden. Mode switches
|
||||
and status refreshes preserve both sections' disclosure state.
|
||||
|
||||
## Central reader
|
||||
|
||||
`files-preview-view.tsx` selects the shared diff reader or the editable workspace
|
||||
reader (source, rendered Markdown, raster/vector image, or binary fallback).
|
||||
The composer stays mounted below it. Selections are bound to the originating
|
||||
project/session and cleared on session/worktree changes. Opening the browser
|
||||
preview replaces the file reader; opening a file returns the browser to chat mode.
|
||||
Cancelling or hiding a view fences pending reads. Workspace/local-diff readers
|
||||
refresh on filesystem invalidation, while commit contents remain immutable.
|
||||
|
||||
Git image previews retain base64 bytes for the actual compared revisions:
|
||||
HEAD/index for staged changes, index/worktree for unstaged changes, and the
|
||||
parent/commit blobs for history. `git-image-preview.ts` uses bounded worker reads
|
||||
and the existing bounded filesystem reader; no checkout occurs and historical
|
||||
images never fall back to the current worktree. `GitImagePreview` shows before
|
||||
and after images (including one-sided additions/deletions); other binary files
|
||||
retain their unavailable-preview fallback.
|
||||
|
||||
Workspace source retains Monaco editing, Save and Ctrl/Cmd+S in the central view;
|
||||
Markdown's source mode is editable too. Images, binary content and historical
|
||||
diffs remain read-only. The former sidebar layout is retired, not file editing.
|
||||
Dirty drafts are retained in renderer memory per instance/directory/path when
|
||||
closing or changing readers, and filesystem invalidation never replaces them.
|
||||
Saving rereads the bounded authoritative file and asks before overwriting external
|
||||
changes; read/write errors preserve the draft. Explicit refresh asks before
|
||||
discarding edits. Monaco's tokenizers are packaged locally for offline highlighting.
|
||||
|
||||
Clicked readers have a reserved one-request lane beside the two background scan
|
||||
slots. Start the local Monaco load alongside the content read; preview Git work
|
||||
uses the worker's foreground queue. Commit metadata and parent IDs share one Git
|
||||
process. These changes preserve ownership checks and cancellation fencing.
|
||||
|
||||
`lib/monaco/theme.ts` resolves the selected palette's CSS tokens into Monaco hex
|
||||
colors, including source tokens, editor surfaces and added/removed-line tints.
|
||||
Its observer follows live appearance/custom-palette changes and is disposed with
|
||||
the viewer. Read-only previews disable cross-model occurrence highlighting to
|
||||
avoid Monaco 0.52's leaked cancellation on fast model changes; text selection and
|
||||
insertion into the composer remain available.
|
||||
|
||||
## Backend boundaries
|
||||
|
||||
- `/api/workspaces/:id/files/preview` accepts a relative `path` and the browsed
|
||||
`directory`, validates it through the manager's existing worktree ownership,
|
||||
and uses the bounded filesystem content reader (5 MiB, base64). This also works
|
||||
for the explicitly opened directory in Git-degraded mode.
|
||||
- `/api/workspaces/:id/worktrees/:slug/git-history` returns up to 50 commits plus
|
||||
a continuation flag. Later pages retain their original HEAD hash.
|
||||
- `PUT /api/workspaces/:id/files/content` accepts the exact browsed `directory`
|
||||
alongside the relative path, checks current ownership and the deletion fence,
|
||||
and uses the existing filesystem writer. It never redirects a linked-worktree
|
||||
edit to the root checkout.
|
||||
- `/git-history/:commit` returns the message and files; an optional `path` returns
|
||||
before/after text against the first parent (empty for a root commit).
|
||||
- Commit IDs are full SHA-1/SHA-256 hashes. Files must belong to the commit's
|
||||
authoritative change list. Git commands run in the existing worker with a
|
||||
timeout and output bound. Renames use their original path; gitlinks show their
|
||||
object IDs rather than reading another repository. Binary diffs use a fallback.
|
||||
|
||||
## Validation
|
||||
|
||||
- `server/routes/files-history.test.ts`: real temporary root/linked repositories,
|
||||
route ownership, file bounds, history/diff and directory-only reading.
|
||||
- `workspaces/git-history.test.ts`: root/merge/rename/delete/binary/gitlink cases
|
||||
and snapshot pagination across a new HEAD.
|
||||
- `tests/browser/git-history.test.ts`: the real panel/readers and SessionView,
|
||||
draft preservation, late-read/session fencing, filesystem refresh and keyboard
|
||||
tree navigation. The design fixture remains available through
|
||||
`node scripts/preview-git-history.mjs` with synthetic data only.
|
||||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
RecentFolder,
|
||||
} from "./config/schema"
|
||||
import type { OpenCodeEvent } from "@opencode/client"
|
||||
export type { GitHistoryCommit, GitHistoryPage, GitCommitFile, GitCommitDetails, GitCommitDiff } from "./git-history-types"
|
||||
|
||||
/**
|
||||
* Canonical HTTP/SSE contract for the CLI server.
|
||||
|
|
@ -188,6 +189,7 @@ export interface WorktreeGitDiffResponse {
|
|||
before: string
|
||||
after: string
|
||||
isBinary?: boolean
|
||||
image?: import("./git-history-types").GitImageDiff
|
||||
}
|
||||
|
||||
export interface WorktreeGitDiffRequest {
|
||||
|
|
|
|||
42
packages/server/src/git-history-types.ts
Normal file
42
packages/server/src/git-history-types.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export interface GitHistoryCommit {
|
||||
id: string
|
||||
parents: string[]
|
||||
author: string
|
||||
date: string
|
||||
subject: string
|
||||
refs: string
|
||||
}
|
||||
|
||||
export interface GitHistoryPage {
|
||||
head: string | null
|
||||
branch: string | null
|
||||
commits: GitHistoryCommit[]
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface GitCommitFile {
|
||||
path: string
|
||||
originalPath: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface GitCommitDetails {
|
||||
id: string
|
||||
parent: string | null
|
||||
message: string
|
||||
files: GitCommitFile[]
|
||||
}
|
||||
|
||||
export interface GitCommitDiff {
|
||||
path: string
|
||||
before: string
|
||||
after: string
|
||||
isBinary: boolean
|
||||
image?: GitImageDiff
|
||||
}
|
||||
|
||||
export interface GitImageDiff {
|
||||
mime: string
|
||||
before: string | null
|
||||
after: string | null
|
||||
}
|
||||
100
packages/server/src/server/routes/files-history.test.ts
Normal file
100
packages/server/src/server/routes/files-history.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import Fastify from "fastify"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"
|
||||
import { invalidateWorktreeCache } from "../../workspaces/worktree-directory"
|
||||
import { registerWorkspaceRoutes } from "./workspaces"
|
||||
|
||||
test("real Git history routes and bounded file previews use the requested owned worktree", async () => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), "codenomad-files-"))
|
||||
const root = path.join(temp, "root"), linked = path.join(temp, "linked"), outside = path.join(temp, "outside")
|
||||
const git = (cwd: string, ...args: string[]) => execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", windowsHide: true }).trim()
|
||||
const id = path.basename(temp)
|
||||
const app = Fastify()
|
||||
const ownershipReads: string[] = []
|
||||
try {
|
||||
await mkdir(root); await mkdir(outside)
|
||||
git(root, "init", "-b", "main")
|
||||
git(root, "config", "user.name", "Fixture"); git(root, "config", "user.email", "fixture@example.invalid")
|
||||
await writeFile(path.join(root, "notes.txt"), "root content")
|
||||
git(root, "add", "."); git(root, "commit", "-m", "Root commit")
|
||||
git(root, "worktree", "add", "-b", "feature", linked)
|
||||
await writeFile(path.join(linked, "notes.txt"), "linked content")
|
||||
git(linked, "add", "."); git(linked, "commit", "-m", "Linked commit")
|
||||
await writeFile(path.join(outside, "secret.txt"), "outside authority")
|
||||
await writeFile(path.join(linked, "image.png"), Buffer.from([137, 80, 78, 71, 0, 13, 10]))
|
||||
await writeFile(path.join(linked, "oversize.bin"), Buffer.alloc(5 * 1024 * 1024 + 1))
|
||||
const workspace = { id, path: root }
|
||||
// Exercise the actual manager fileBrowserRoot + previewFile boundary. Native
|
||||
// discovery is isolated to an explicit inventory, with no shared daemon.
|
||||
const manager = Object.assign(Object.create(WorkspaceManager.prototype), {
|
||||
get: (requested: string) => requested === id ? workspace : undefined,
|
||||
getWorktreeIdentityForPath: async (_id: string, directory: string) => directory === root || directory === linked ? directory : undefined,
|
||||
requireWorkspace: (requested: string) => { if (requested !== id) throw new Error("Workspace not found"); return workspace },
|
||||
resolveOwnedWorktree: async (_workspace: unknown, directory: string) => {
|
||||
ownershipReads.push(directory)
|
||||
return directory === root || directory === linked ? { directory } : null
|
||||
},
|
||||
getWorktrees: async () => ({ worktrees: [
|
||||
{ slug: "root", directory: root, kind: "root" }, { slug: "linked", directory: linked, kind: "worktree" },
|
||||
] }),
|
||||
}) as WorkspaceManager
|
||||
const fence = new WorktreeDeletionFence()
|
||||
registerWorkspaceRoutes(app, { workspaceManager: manager, worktreeDeletionFence: fence })
|
||||
const base = `/api/workspaces/${id}`
|
||||
const preview = (file: string, directory: string) => app.inject(`${base}/files/preview?${new URLSearchParams({ path: file, directory })}`)
|
||||
const rootFile = await preview("notes.txt", root), linkedFile = await preview("notes.txt", linked)
|
||||
assert.equal(rootFile.statusCode, 200); assert.equal(linkedFile.statusCode, 200)
|
||||
assert.equal(Buffer.from(rootFile.json().contents, "base64").toString(), "root content")
|
||||
assert.equal(Buffer.from(linkedFile.json().contents, "base64").toString(), "linked content")
|
||||
const save = (directory: string, file = "notes.txt") => app.inject({ method: "PUT",
|
||||
url: `${base}/files/content?${new URLSearchParams({ path: file, directory })}`,
|
||||
payload: { contents: "edited workspace" } })
|
||||
assert.equal((await save(linked)).statusCode, 204)
|
||||
assert.equal(await readFile(path.join(linked, "notes.txt"), "utf8"), "edited workspace")
|
||||
assert.equal(await readFile(path.join(root, "notes.txt"), "utf8"), "root content")
|
||||
assert.equal((await save(outside)).statusCode, 403)
|
||||
assert.equal((await save(linked, "../outside/secret.txt")).statusCode, 400)
|
||||
const identity = await manager.getWorktreeIdentityForPath(id, linked) as string
|
||||
await fence.run(identity, [identity], async () => { assert.equal((await save(linked)).statusCode, 409) })
|
||||
assert.equal((await preview("image.png", linked)).json().encoding, "base64")
|
||||
assert.equal((await preview("oversize.bin", linked)).statusCode, 400)
|
||||
assert.equal((await preview("secret.txt", outside)).statusCode, 400)
|
||||
assert.equal((await preview("../outside/secret.txt", root)).statusCode, 400)
|
||||
assert.ok(ownershipReads.includes(outside))
|
||||
const history = await app.inject(`${base}/worktrees/linked/git-history`)
|
||||
assert.equal(history.statusCode, 200)
|
||||
assert.equal(history.json().branch, "feature")
|
||||
assert.equal(history.json().commits[0].subject, "Linked commit")
|
||||
const commit = history.json().commits[0].id
|
||||
const diff = await app.inject(`${base}/worktrees/linked/git-history/${commit}?path=notes.txt`)
|
||||
assert.equal(diff.statusCode, 200)
|
||||
assert.equal(diff.json().before, "root content"); assert.equal(diff.json().after, "linked content")
|
||||
assert.equal((await app.inject(`${base}/worktrees/missing/git-history`)).statusCode, 404)
|
||||
assert.equal((await app.inject(`${base}/worktrees/root/git-history?head=--all`)).statusCode, 400)
|
||||
assert.equal((await app.inject(`${base}/worktrees/root/git-history/${commit}?path=not-in-commit`)).statusCode, 400)
|
||||
assert.equal(git(root, "branch", "--show-current"), "main")
|
||||
} finally { invalidateWorktreeCache(id); await app.close(); await rm(temp, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
test("file preview remains available for an explicitly opened directory without Git", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-directory-preview-"))
|
||||
try {
|
||||
await writeFile(path.join(root, "readme.md"), "# Directory only")
|
||||
const manager = Object.assign(Object.create(WorkspaceManager.prototype), {
|
||||
requireWorkspace: () => ({ path: root }),
|
||||
resolveOwnedWorktree: async (_workspace: unknown, directory: string) => directory === root ? { directory } : null,
|
||||
}) as WorkspaceManager
|
||||
const file = await manager.previewFile("fixture", "readme.md", root)
|
||||
assert.equal(Buffer.from(file.contents, "base64").toString(), "# Directory only")
|
||||
await assert.rejects(manager.previewFile("fixture", "readme.md", path.dirname(root)), /not owned/)
|
||||
await manager.writeFileInDirectory("fixture", root, "readme.md", "# Edited directory")
|
||||
assert.equal(await readFile(path.join(root, "readme.md"), "utf8"), "# Edited directory")
|
||||
await assert.rejects(manager.writeFileInDirectory("fixture", path.dirname(root), "readme.md", "invalid"), /not owned/)
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
|
@ -10,6 +10,7 @@ import type { WorktreeDeletionFence } from "../../workspaces/worktree-session-ev
|
|||
import { WorkspaceSearchBusyError } from "../../filesystem/search-cache"
|
||||
import { UnsupportedOpenCodeError } from "../../opencode/runtime-support"
|
||||
import { GitRequiredError } from "../../workspaces/git-requirement"
|
||||
import { getGitHistory, getGitCommit, getGitCommitDiff } from "../../workspaces/git-history"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
|
|
@ -39,6 +40,7 @@ const WorkspaceFilesQuerySchema = z.object({
|
|||
|
||||
const WorkspaceFileContentQuerySchema = z.object({
|
||||
path: z.string(),
|
||||
directory: z.string().trim().min(1).optional(),
|
||||
encoding: z.enum(["utf-8", "base64"]).optional(),
|
||||
worktree: z.string().trim().optional(),
|
||||
})
|
||||
|
|
@ -73,6 +75,31 @@ const WorkspaceFileSearchQuerySchema = z.object({
|
|||
})
|
||||
|
||||
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id/files/preview", async (request, reply) => {
|
||||
try {
|
||||
const query = z.object({ path: z.string().min(1), directory: z.string().min(1).optional() }).parse(request.query)
|
||||
return await deps.workspaceManager.previewFile(request.params.id, query.path, query.directory)
|
||||
} catch (error) { return handleWorkspaceError(error, reply) }
|
||||
})
|
||||
app.get<{ Params: { id: string; slug: string } }>("/api/workspaces/:id/worktrees/:slug/git-history", async (request, reply) => {
|
||||
try {
|
||||
const query = z.object({ offset: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0), head: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/).optional() }).parse(request.query)
|
||||
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||
if (!directory) return
|
||||
return await getGitHistory(directory, query.offset, query.head)
|
||||
} catch (error) { return handleWorkspaceError(error, reply) }
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string; slug: string; commit: string } }>("/api/workspaces/:id/worktrees/:slug/git-history/:commit", async (request, reply) => {
|
||||
try {
|
||||
const query = z.object({ path: z.string().min(1).optional() }).parse(request.query)
|
||||
const commit = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/).parse(request.params.commit)
|
||||
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||
if (!directory) return
|
||||
return query.path === undefined ? await getGitCommit(directory, commit)
|
||||
: await getGitCommitDiff(directory, commit, query.path)
|
||||
} catch (error) { return handleWorkspaceError(error, reply) }
|
||||
})
|
||||
app.get("/api/workspaces", async () => {
|
||||
return deps.workspaceManager.list()
|
||||
})
|
||||
|
|
@ -200,6 +227,15 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||
try {
|
||||
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
||||
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
|
||||
if (query.directory) {
|
||||
const directory = query.directory
|
||||
const mutation = await runWorktreeMutation(deps, request.params.id, directory, reply, async () => {
|
||||
await deps.workspaceManager.writeFileInDirectory(request.params.id, directory, query.path, body.contents)
|
||||
})
|
||||
if (!mutation) return
|
||||
reply.code(204)
|
||||
return
|
||||
}
|
||||
if (query.worktree && query.worktree !== "root") {
|
||||
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, query.worktree, request.log, reply)
|
||||
if (!directory) return
|
||||
|
|
|
|||
75
packages/server/src/workspaces/git-history.test.ts
Normal file
75
packages/server/src/workspaces/git-history.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { getGitHistory, getGitCommit, getGitCommitDiff } from "./git-history"
|
||||
|
||||
test("history reads real root, renamed, deleted, binary and merge files without changing HEAD", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "git-history-"))
|
||||
const git = (...args: string[]) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", windowsHide: true }).trim()
|
||||
const commit = (message: string) => { git("add", "."); git("commit", "-m", message); return git("rev-parse", "HEAD") }
|
||||
try {
|
||||
git("init", "-b", "main")
|
||||
git("config", "user.name", "History Fixture")
|
||||
git("config", "user.email", "fixture@example.invalid")
|
||||
git("config", "core.autocrlf", "false")
|
||||
assert.deepEqual((await getGitHistory(root)).commits, [])
|
||||
await writeFile(path.join(root, "before name.txt"), "before\n")
|
||||
const first = commit("Initial history")
|
||||
assert.equal((await getGitHistory(root)).branch, "main")
|
||||
assert.deepEqual(await getGitCommitDiff(root, first, "before name.txt"), { path: "before name.txt", before: "", after: "before\n", isBinary: false })
|
||||
git("mv", "before name.txt", "after name.txt")
|
||||
const renamed = commit("Rename file")
|
||||
assert.deepEqual((await getGitCommit(root, renamed)).files, [{ path: "after name.txt", originalPath: "before name.txt", status: "R" }])
|
||||
assert.equal((await getGitCommitDiff(root, renamed, "after name.txt")).before, "before\n")
|
||||
git("rm", "after name.txt")
|
||||
await writeFile(path.join(root, "binary.dat"), Buffer.from([0, 1, 2]))
|
||||
const deleted = commit("Delete text and add binary")
|
||||
assert.equal((await getGitCommitDiff(root, deleted, "after name.txt")).after, "")
|
||||
assert.equal((await getGitCommitDiff(root, deleted, "binary.dat")).isBinary, true)
|
||||
await assert.rejects(getGitCommitDiff(root, deleted, "../outside"), /does not belong/)
|
||||
await assert.rejects(getGitCommit(root, "--output=escape"), /Invalid commit/)
|
||||
const blob = git("rev-parse", `${deleted}:binary.dat`)
|
||||
await assert.rejects(getGitCommit(root, blob), /not a commit/)
|
||||
git("checkout", "-b", "side")
|
||||
await writeFile(path.join(root, "side.txt"), "side\n")
|
||||
commit("Side branch")
|
||||
git("checkout", "main")
|
||||
git("merge", "--no-ff", "side", "-m", "Merge side")
|
||||
const head = git("rev-parse", "HEAD")
|
||||
const history = await getGitHistory(root)
|
||||
assert.equal(history.commits[0]?.id, head)
|
||||
assert.equal(history.commits[0]?.parents.length, 2)
|
||||
assert.equal((await getGitCommit(root, head)).parent, deleted)
|
||||
assert.equal((await getGitCommitDiff(root, head, "side.txt")).after, "side\n")
|
||||
assert.equal(git("rev-parse", "HEAD"), head)
|
||||
assert.equal(git("status", "--porcelain"), "")
|
||||
git("update-index", "--add", "--cacheinfo", `160000,${first},nested-repository`)
|
||||
git("commit", "-m", "Add submodule reference")
|
||||
const submodule = await getGitCommitDiff(root, git("rev-parse", "HEAD"), "nested-repository")
|
||||
assert.equal(submodule.after, `Subproject commit ${first}\n`)
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
test("history pages retain their original HEAD when a new commit arrives", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "git-history-pages-"))
|
||||
const git = (...args: string[]) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", windowsHide: true }).trim()
|
||||
try {
|
||||
git("init", "-b", "main")
|
||||
git("config", "user.name", "History Fixture")
|
||||
git("config", "user.email", "fixture@example.invalid")
|
||||
for (let i = 0; i < 52; i++) git("commit", "--allow-empty", "-m", `Commit ${i}`)
|
||||
const first = await getGitHistory(root)
|
||||
assert.equal(first.commits.length, 50)
|
||||
assert.equal(first.hasMore, true)
|
||||
git("commit", "--allow-empty", "-m", "New head")
|
||||
const second = await getGitHistory(root, 50, first.head!)
|
||||
assert.equal(second.commits.length, 2)
|
||||
assert.equal(second.commits[1]?.subject, "Commit 0")
|
||||
assert.equal(second.hasMore, false)
|
||||
assert.equal(new Set([...first.commits, ...second.commits].map(commit => commit.id)).size, 52)
|
||||
await assert.rejects(getGitHistory(root, -1), /Invalid history/)
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
86
packages/server/src/workspaces/git-history.ts
Normal file
86
packages/server/src/workspaces/git-history.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { GitCommitDetails, GitCommitFile, GitHistoryPage, GitCommitDiff } from "../git-history-types"
|
||||
import { runGitProcess } from "./git-process"
|
||||
import { gitImageMime } from "./git-image-preview"
|
||||
|
||||
const git = (directory: string, args: string[]) => runGitProcess(directory, args, { timeout: 15_000, maxBuffer: 2 * 1024 * 1024, priority: "foreground" })
|
||||
|
||||
function commitId(value: string): string {
|
||||
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value)) throw new Error("Invalid commit ID")
|
||||
return value
|
||||
}
|
||||
|
||||
async function headId(directory: string): Promise<string | null> {
|
||||
try { return (await git(directory, ["rev-parse", "--verify", "--quiet", "HEAD"])).trim() }
|
||||
catch (error) {
|
||||
if ((error as { code?: number }).code === 1) return null // Unborn branch.
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGitHistory(directory: string, offset = 0, snapshot?: string): Promise<GitHistoryPage> {
|
||||
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid history offset")
|
||||
const head = snapshot ? commitId(snapshot) : await headId(directory)
|
||||
const branch = (await git(directory, ["branch", "--show-current"])).trim() || null
|
||||
if (!head) return { head, branch, commits: [], hasMore: false }
|
||||
const output = await git(directory, ["log", "-z", "--format=%H%x00%P%x00%an%x00%aI%x00%s%x00%D", "--max-count=51", `--skip=${offset}`, head, "--"])
|
||||
const fields = output.split("\0")
|
||||
const commits = []
|
||||
for (let i = 0; i + 5 < fields.length; i += 6) {
|
||||
commits.push({ id: fields[i]!, parents: fields[i + 1]!.split(" ").filter(Boolean), author: fields[i + 2]!,
|
||||
date: fields[i + 3]!, subject: fields[i + 4]!, refs: fields[i + 5]! })
|
||||
}
|
||||
return { head, branch, commits: commits.slice(0, 50), hasMore: commits.length > 50 }
|
||||
}
|
||||
|
||||
export async function getGitCommit(directory: string, revision: string): Promise<GitCommitDetails> {
|
||||
const id = commitId(revision)
|
||||
// Validate the object as a commit and read its metadata in one bounded process.
|
||||
const metadata = await git(directory, ["show", "-s", "--format=%P%x00%B", `${id}^{commit}`, "--"])
|
||||
.catch(cause => { throw Object.assign(new Error("Object is not a commit"), { cause }) })
|
||||
const separator = metadata.indexOf("\0")
|
||||
const parents = metadata.slice(0, separator).trim().split(" ").filter(Boolean)
|
||||
const parent = parents[0] ? commitId(parents[0]) : null
|
||||
const output = await git(directory, ["diff-tree", "--root", "--no-commit-id", "-r", "-M", "--name-status", "-z", ...(parent ? [parent, id] : [id]), "--"])
|
||||
const tokens = output.split("\0")
|
||||
const files: GitCommitFile[] = []
|
||||
for (let i = 0; i < tokens.length && tokens[i];) {
|
||||
const status = tokens[i++]!
|
||||
const first = tokens[i++]!
|
||||
const renamed = status.startsWith("R") || status.startsWith("C")
|
||||
const path = renamed ? tokens[i++]! : first
|
||||
files.push({ path, originalPath: renamed ? first : null, status: status[0]! })
|
||||
}
|
||||
const message = metadata.slice(separator + 1).trimEnd()
|
||||
return { id, parent, message, files }
|
||||
}
|
||||
|
||||
export async function getGitCommitDiff(directory: string, revision: string, filePath: string): Promise<GitCommitDiff> {
|
||||
const commit = await getGitCommit(directory, revision)
|
||||
// Authoritative file membership prevents arbitrary revision/path expressions.
|
||||
const file = commit.files.find(file => file.path === filePath)
|
||||
if (!file) throw new Error("File does not belong to this commit")
|
||||
const mime = gitImageMime(file.path)
|
||||
if (mime) {
|
||||
const [before, after] = await Promise.all([
|
||||
!commit.parent || file.status === "A" ? null : readCommitFile(directory, commit.parent, file.originalPath ?? file.path, "base64"),
|
||||
file.status === "D" ? null : readCommitFile(directory, commit.id, file.path, "base64"),
|
||||
])
|
||||
return { path: file.path, before: "", after: "", isBinary: true, image: { mime, before, after } }
|
||||
}
|
||||
const [before, after] = await Promise.all([
|
||||
!commit.parent || file.status === "A" ? "" : readCommitFile(directory, commit.parent, file.originalPath ?? file.path),
|
||||
file.status === "D" ? "" : readCommitFile(directory, commit.id, file.path),
|
||||
])
|
||||
const isBinary = before.includes("\0") || after.includes("\0")
|
||||
return { path: file.path, before: isBinary ? "" : before, after: isBinary ? "" : after, isBinary }
|
||||
}
|
||||
|
||||
async function readCommitFile(directory: string, revision: string, filePath: string, encoding: "utf8" | "base64" = "utf8"): Promise<string> {
|
||||
const entry = await git(directory, ["ls-tree", "-z", revision, "--", `:(literal)${filePath}`])
|
||||
const match = /^(\d{6}) (blob|commit) ([a-f0-9]{40}|[a-f0-9]{64})\t/.exec(entry)
|
||||
if (!match) throw new Error("Commit file is unavailable")
|
||||
// Gitlinks point to another repository; never read a submodule's working tree
|
||||
// or mistake its commit object/message for the file's contents.
|
||||
if (match[1] === "160000") return `Subproject commit ${match[3]}\n`
|
||||
return runGitProcess(directory, ["cat-file", "blob", match[3]!], { encoding, timeout: 15_000, maxBuffer: (encoding === "base64" ? 5 : 2) * 1024 * 1024, priority: "foreground" })
|
||||
}
|
||||
54
packages/server/src/workspaces/git-image-preview.test.ts
Normal file
54
packages/server/src/workspaces/git-image-preview.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { getWorktreeGitDiff } from "./git-status"
|
||||
import { getGitCommitDiff } from "./git-history"
|
||||
import { runGitProcess } from "./git-process"
|
||||
|
||||
test("image previews retain exact HEAD/index/worktree and historical bytes with bounded reads", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "git-image-preview-"))
|
||||
const git = (...args: string[]) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", windowsHide: true }).trim()
|
||||
const initial = Buffer.from([137, 80, 78, 71, 0, 13, 10, 255, 128])
|
||||
const indexed = Buffer.from([...initial, 1])
|
||||
const working = Buffer.from([...indexed, 2])
|
||||
const diff = (scope: "staged" | "unstaged", file = "image.png") => getWorktreeGitDiff({ workspaceFolder: root, path: file, scope })
|
||||
try {
|
||||
git("init", "-b", "main")
|
||||
git("config", "user.name", "Image Fixture")
|
||||
git("config", "user.email", "fixture@example.invalid")
|
||||
git("config", "core.autocrlf", "false")
|
||||
await writeFile(path.join(root, "image.png"), initial)
|
||||
assert.deepEqual((await diff("unstaged")).image, { mime: "image/png", before: null, after: initial.toString("base64") })
|
||||
git("add", ".")
|
||||
git("commit", "-m", "Initial image")
|
||||
const first = git("rev-parse", "HEAD")
|
||||
assert.deepEqual((await getGitCommitDiff(root, first, "image.png")).image, { mime: "image/png", before: null, after: initial.toString("base64") })
|
||||
await writeFile(path.join(root, "image.png"), indexed)
|
||||
git("add", ".")
|
||||
await writeFile(path.join(root, "image.png"), working)
|
||||
assert.deepEqual((await diff("staged")).image, { mime: "image/png", before: initial.toString("base64"), after: indexed.toString("base64") })
|
||||
assert.deepEqual((await diff("unstaged")).image, { mime: "image/png", before: indexed.toString("base64"), after: working.toString("base64") })
|
||||
git("commit", "-m", "Indexed image only")
|
||||
const second = git("rev-parse", "HEAD")
|
||||
assert.deepEqual((await getGitCommitDiff(root, second, "image.png")).image, { mime: "image/png", before: initial.toString("base64"), after: indexed.toString("base64") })
|
||||
await assert.rejects(getGitCommitDiff(root, second, "outside.png"), /does not belong/)
|
||||
git("add", ".")
|
||||
git("commit", "-m", "Working image")
|
||||
git("mv", "image.png", "renamed.png")
|
||||
git("commit", "-m", "Rename image")
|
||||
const renamed = await getGitCommitDiff(root, git("rev-parse", "HEAD"), "renamed.png")
|
||||
assert.equal(renamed.image?.before, working.toString("base64"))
|
||||
assert.equal(renamed.image?.after, working.toString("base64"))
|
||||
git("rm", "renamed.png")
|
||||
assert.equal((await diff("staged", "renamed.png")).image?.after, null)
|
||||
git("commit", "-m", "Delete image")
|
||||
assert.equal((await getGitCommitDiff(root, git("rev-parse", "HEAD"), "renamed.png")).image?.after, null)
|
||||
await writeFile(path.join(root, "large.png"), Buffer.alloc(6 * 1024 * 1024))
|
||||
await assert.rejects(diff("unstaged", "large.png"), /too large/)
|
||||
git("add", "large.png")
|
||||
await assert.rejects(runGitProcess(root, ["cat-file", "blob", ":large.png"], { encoding: "base64", maxBuffer: 1024 }), /maxBuffer/)
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
37
packages/server/src/workspaces/git-image-preview.ts
Normal file
37
packages/server/src/workspaces/git-image-preview.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { realpath } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { runGitProcess } from "./git-process"
|
||||
|
||||
const imageTypes: Record<string, string> = {
|
||||
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp",
|
||||
gif: "image/gif", svg: "image/svg+xml", ico: "image/x-icon", bmp: "image/bmp", avif: "image/avif",
|
||||
}
|
||||
export const gitImageMime = (filePath: string) => imageTypes[path.extname(filePath).slice(1).toLowerCase()]
|
||||
|
||||
export async function readGitImageBlob(directory: string, object: string): Promise<string | null> {
|
||||
try {
|
||||
return await runGitProcess(directory, ["cat-file", "blob", object], {
|
||||
encoding: "base64", maxBuffer: 5 * 1024 * 1024, timeout: 15_000, priority: "foreground",
|
||||
})
|
||||
} catch (error) {
|
||||
// Known HEAD/index paths may be absent for additions, deletions or unborn HEAD.
|
||||
// Size limits and process failures remain errors, not missing images.
|
||||
const failure = error as { code?: unknown; stderr?: string }
|
||||
if (failure.code === 128 && /does not exist|not in|invalid object name|not a valid object name|could not get object info/i.test(failure.stderr ?? "")) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function readWorktreeImage(directory: string, filePath: string): Promise<string | null> {
|
||||
try {
|
||||
const root = await realpath(directory)
|
||||
const resolved = await realpath(path.join(root, filePath))
|
||||
const relative = path.relative(root, resolved)
|
||||
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error("Image is outside the worktree")
|
||||
return (await new FileSystemBrowser({ rootDir: root }).readFileContent(relative, { encoding: "base64" })).contents
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Worker } from "node:worker_threads"
|
||||
|
||||
interface GitProcessOptions { timeout?: number; maxBuffer?: number; priority?: "foreground" | "background" }
|
||||
interface GitProcessOptions { timeout?: number; maxBuffer?: number; priority?: "foreground" | "background"; encoding?: "utf8" | "base64" }
|
||||
interface GitRequest extends GitProcessOptions { id: number; directory: string; args: string[]; env: NodeJS.ProcessEnv }
|
||||
interface GitResponse { id: number; stdout: string; stderr: string; error?: { message: string; code?: string | number | null } }
|
||||
|
||||
|
|
@ -18,10 +18,10 @@ function workerMain() {
|
|||
const request = (foreground.shift() ?? queue.shift())!
|
||||
running += 1
|
||||
execFile("git", ["-C", request.directory, ...request.args], {
|
||||
encoding: "utf8", windowsHide: true, maxBuffer: request.maxBuffer ?? 1024 * 1024,
|
||||
encoding: "buffer", windowsHide: true, maxBuffer: request.maxBuffer ?? 1024 * 1024,
|
||||
env: request.env, timeout: request.timeout,
|
||||
}, (error, stdout, stderr) => {
|
||||
parentPort!.postMessage({ id: request.id, stdout, stderr,
|
||||
parentPort!.postMessage({ id: request.id, stdout: stdout.toString(request.encoding ?? "utf8"), stderr: stderr.toString("utf8"),
|
||||
...(error ? { error: { message: error.message, code: error.code } } : {}),
|
||||
} satisfies GitResponse)
|
||||
running -= 1
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, Work
|
|||
import type { LogLike } from "./git-worktrees"
|
||||
import { normalizeGitWorktreeRelativePath } from "./git-mutations"
|
||||
import { runGitProcess } from "./git-process"
|
||||
import { gitImageMime, readGitImageBlob, readWorktreeImage } from "./git-image-preview"
|
||||
|
||||
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||
type GitSuccessResult = Extract<GitResult, { ok: true }>
|
||||
|
|
@ -24,11 +25,11 @@ async function readGitBlobAsDiffText(resultPromise: Promise<GitResult>, missingO
|
|||
return result.stdout
|
||||
}
|
||||
|
||||
async function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0]): Promise<GitResult> {
|
||||
async function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0], priority: "foreground" | "background" = "background"): Promise<GitResult> {
|
||||
try {
|
||||
// Preserve the previous streaming reader's unrestricted content size. Process
|
||||
// creation belongs to the worker, including per-untracked-file numstat reads.
|
||||
return { ok: true, stdout: await runGitProcess(cwd, args, { maxBuffer: Infinity }) }
|
||||
return { ok: true, stdout: await runGitProcess(cwd, args, { maxBuffer: Infinity, priority }) }
|
||||
} catch (cause) {
|
||||
const result = cause as Error & { code?: string | number; stdout?: string; stderr?: string }
|
||||
const stdout = result.stdout ?? "", stderr = result.stderr ?? ""
|
||||
|
|
@ -271,7 +272,7 @@ function decodeGitShowResult(result: GitResult, missingOk = false): string {
|
|||
}
|
||||
|
||||
async function readGitIndexBlob(workspaceFolder: string, normalizedPath: string): Promise<GitResult> {
|
||||
return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder)
|
||||
return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder, [0], "foreground")
|
||||
}
|
||||
|
||||
async function getTrackedDiffMetadata(params: {
|
||||
|
|
@ -290,7 +291,7 @@ async function getTrackedDiffMetadata(params: {
|
|||
args.push(params.normalizedOriginalPath)
|
||||
}
|
||||
|
||||
const result = await runGit(args, params.workspaceFolder)
|
||||
const result = await runGit(args, params.workspaceFolder, [0], "foreground")
|
||||
if (!result.ok) {
|
||||
throw result.error
|
||||
}
|
||||
|
|
@ -304,7 +305,7 @@ async function getUntrackedDiffMetadata(params: {
|
|||
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])
|
||||
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1], "foreground")
|
||||
if (!result.ok) {
|
||||
throw result.error
|
||||
}
|
||||
|
|
@ -348,6 +349,20 @@ export async function getWorktreeGitDiff(params: {
|
|||
})
|
||||
: trackedMetadata
|
||||
|
||||
const mime = gitImageMime(normalizedPath)
|
||||
if (mime) {
|
||||
let before = await readGitImageBlob(params.workspaceFolder, params.scope === "staged"
|
||||
? `HEAD:${normalizedOriginalPath ?? normalizedPath}` : `:${normalizedPath}`)
|
||||
if (before === null && params.scope === "unstaged" && normalizedOriginalPath) {
|
||||
before = await readGitImageBlob(params.workspaceFolder, `:${normalizedOriginalPath}`)
|
||||
}
|
||||
const after = params.scope === "staged"
|
||||
? await readGitImageBlob(params.workspaceFolder, `:${normalizedPath}`)
|
||||
: await readWorktreeImage(params.workspaceFolder, normalizedPath)
|
||||
return { path: normalizedPath, originalPath: normalizedOriginalPath, scope: params.scope,
|
||||
before: "", after: "", isBinary: true, image: { mime, before, after } }
|
||||
}
|
||||
|
||||
if (diffMetadata.isBinary) {
|
||||
return {
|
||||
path: normalizedPath,
|
||||
|
|
@ -361,7 +376,7 @@ export async function getWorktreeGitDiff(params: {
|
|||
|
||||
if (params.scope === "staged") {
|
||||
const [beforeResult, afterResult] = await Promise.all([
|
||||
readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder), true),
|
||||
readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder, [0], "foreground"), true),
|
||||
readGitBlobAsDiffText(readGitIndexBlob(params.workspaceFolder, normalizedPath), true),
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -492,6 +492,12 @@ export class WorkspaceManager {
|
|||
}
|
||||
}
|
||||
|
||||
async previewFile(workspaceId: string, relativePath: string, directory?: string): Promise<WorkspaceFileResponse> {
|
||||
const browser = new FileSystemBrowser({ rootDir: await this.fileBrowserRoot(workspaceId, directory) })
|
||||
const result = await browser.readFileContent(relativePath, { encoding: "base64" })
|
||||
return { workspaceId, relativePath, contents: result.contents, encoding: "base64" }
|
||||
}
|
||||
|
||||
async readFileInDirectory(workspaceId: string, directory: string, relativePath: string, options?: { encoding?: "utf-8" | "base64" }): Promise<WorkspaceFileResponse> {
|
||||
this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: directory })
|
||||
|
|
@ -512,8 +518,7 @@ export class WorkspaceManager {
|
|||
}
|
||||
|
||||
async writeFileInDirectory(workspaceId: string, directory: string, relativePath: string, contents: string): Promise<void> {
|
||||
this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: directory })
|
||||
const browser = new FileSystemBrowser({ rootDir: await this.fileBrowserRoot(workspaceId, directory) })
|
||||
await browser.writeFile(relativePath, contents)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,13 +77,9 @@ export function copyMonacoPublicAssets(params) {
|
|||
}
|
||||
}
|
||||
|
||||
// Copy baseline basic tokenizers.
|
||||
for (const lang of ["python", "markdown", "cpp", "kotlin"]) {
|
||||
const src = resolve(sourceRoot, "basic-languages", lang)
|
||||
if (fs.existsSync(src)) {
|
||||
copyRecursive(src, resolve(destRoot, "basic-languages", lang))
|
||||
}
|
||||
}
|
||||
// Workspace previews may open any supported language, including YAML and
|
||||
// TypeScript. Keep tokenizers local so source views work without a CDN.
|
||||
copyRecursive(resolve(sourceRoot, "basic-languages"), resolve(destRoot, "basic-languages"))
|
||||
|
||||
// Copy monaco.contribution.js entrypoints (needed by some loads).
|
||||
const monacoContribution = resolve(sourceRoot, "basic-languages", "monaco.contribution.js")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { loadMonaco } from "../../lib/monaco/setup"
|
|||
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
||||
import { useTheme } from "../../lib/theme"
|
||||
import { useMonacoTheme } from "../../lib/monaco/theme"
|
||||
import { parsePatchToBeforeAfter } from "../../lib/diff-utils"
|
||||
|
||||
interface MonacoDiffViewerProps {
|
||||
|
|
@ -67,13 +67,13 @@ function getSplitGutterSizing(options: { before: string; after: string }) {
|
|||
}
|
||||
|
||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
const { isDark } = useTheme()
|
||||
let host: HTMLDivElement | undefined
|
||||
|
||||
let diffEditor: any = null
|
||||
let monaco: any = null
|
||||
let splitLayoutFrame: number | null = null
|
||||
const [ready, setReady] = createSignal(false)
|
||||
useMonacoTheme(() => ready() ? monaco : null)
|
||||
const [hoveredLine, setHoveredLine] = createSignal<number | null>(null)
|
||||
const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null)
|
||||
const [widgetHovered, setWidgetHovered] = createSignal(false)
|
||||
|
|
@ -194,9 +194,11 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||
if (cancelled) return
|
||||
if (!host || !monaco) return
|
||||
|
||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||
diffEditor = monaco.editor.createDiffEditor(host, {
|
||||
readOnly: true,
|
||||
// Read-only previews need selection, not cross-model symbol requests.
|
||||
// Monaco 0.52 leaks their cancellation on rapid model replacement.
|
||||
occurrencesHighlight: "off",
|
||||
automaticLayout: true,
|
||||
renderSideBySide: true,
|
||||
renderSideBySideInlineBreakpoint: 0,
|
||||
|
|
@ -235,11 +237,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!host) return
|
||||
host.dataset.viewMode = props.viewMode === "split" ? "split" : "unified"
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import { loadMonaco } from "../../lib/monaco/setup"
|
|||
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
||||
import { useTheme } from "../../lib/theme"
|
||||
import { useMonacoTheme } from "../../lib/monaco/theme"
|
||||
|
||||
interface MonacoFileViewerProps {
|
||||
scopeKey: string
|
||||
path: string
|
||||
content: string
|
||||
readOnly?: boolean
|
||||
wordWrap?: "on" | "off"
|
||||
compactGutter?: boolean
|
||||
onSave?: (content: string) => void
|
||||
|
|
@ -16,12 +17,12 @@ interface MonacoFileViewerProps {
|
|||
}
|
||||
|
||||
export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
const { isDark } = useTheme()
|
||||
let host: HTMLDivElement | undefined
|
||||
|
||||
let editor: any = null
|
||||
let monaco: any = null
|
||||
const [ready, setReady] = createSignal(false)
|
||||
useMonacoTheme(() => ready() ? monaco : null)
|
||||
|
||||
const disposeEditor = () => {
|
||||
try {
|
||||
|
|
@ -55,11 +56,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
|||
if (cancelled) return
|
||||
if (!host || !monaco) return
|
||||
|
||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||
editor = monaco.editor.create(host, {
|
||||
value: "",
|
||||
language: "plaintext",
|
||||
readOnly: false,
|
||||
readOnly: props.readOnly ?? false,
|
||||
occurrencesHighlight: props.readOnly ? "off" : "singleFile",
|
||||
automaticLayout: true,
|
||||
lineNumbers: "on",
|
||||
lineNumbersMinChars: lineNumbersMinChars(props.content),
|
||||
|
|
@ -91,11 +92,6 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
|||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !editor) return
|
||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !editor) return
|
||||
editor.updateOptions({ wordWrap: props.wordWrap === "on" ? "on" : "off" })
|
||||
|
|
|
|||
10
packages/ui/src/components/files-preview-view.tsx
Normal file
10
packages/ui/src/components/files-preview-view.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Show } from "solid-js"
|
||||
import type { FilePreviewTarget } from "../stores/files-preview"
|
||||
import { GitDiffView } from "./git-diff-view"
|
||||
import { WorkspaceFileView } from "./workspace-file-view"
|
||||
|
||||
export function FilesPreviewView(props: { instanceId: string; target: FilePreviewTarget; active: boolean; onClose: () => void; onInsertComment?: (text: string) => void }) {
|
||||
return <Show when={props.target.kind === "workspace"} fallback={<GitDiffView {...props} />}>
|
||||
<WorkspaceFileView {...props} />
|
||||
</Show>
|
||||
}
|
||||
95
packages/ui/src/components/git-diff-view.tsx
Normal file
95
packages/ui/src/components/git-diff-view.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { Show, Suspense, createEffect, createSignal, lazy, on, onCleanup } from "solid-js"
|
||||
import { WrapText, X } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { previewReads } from "../lib/background-read-queue"
|
||||
import { loadMonaco } from "../lib/monaco/setup"
|
||||
import type { GitImageDiff } from "../../../server/src/git-history-types"
|
||||
import { GitImagePreview } from "./git-image-preview"
|
||||
import { createDebouncedRefresh, filesystemInvalidationVersion } from "../lib/filesystem-events"
|
||||
import type { FilePreviewTarget } from "../stores/files-preview"
|
||||
import DiffToolbar from "./instance/shell/right-panel/components/DiffToolbar"
|
||||
import type { DiffViewMode, DiffContextMode } from "./instance/shell/right-panel/types"
|
||||
import { writeClientLayoutValue } from "../stores/client-state"
|
||||
import { readStoredEnum, RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY } from "./instance/shell/storage"
|
||||
|
||||
const MonacoDiffViewer = lazy(() => import("./file-viewer/monaco-diff-viewer").then(module => ({ default: module.MonacoDiffViewer })))
|
||||
|
||||
export function GitDiffView(props: { instanceId: string; target: FilePreviewTarget; active: boolean; onClose: () => void; onInsertComment?: (text: string) => void }) {
|
||||
const { t } = useI18n()
|
||||
const [content, setContent] = createSignal<{ before: string; after: string; image?: GitImageDiff } | null>(null)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [view, setView] = createSignal<DiffViewMode>(readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["unified", "split"] as const) ?? "unified")
|
||||
const [context, setContext] = createSignal<DiffContextMode>(readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["collapsed", "expanded"] as const) ?? "collapsed")
|
||||
const [wrap, setWrap] = createSignal(readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) !== "off")
|
||||
createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, view()))
|
||||
createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, context()))
|
||||
createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, wrap() ? "on" : "off"))
|
||||
let controller: AbortController | undefined
|
||||
let pending = false
|
||||
|
||||
async function load() {
|
||||
if (!props.active) return
|
||||
if (loading()) { pending = true; return }
|
||||
const request = controller = new AbortController(), target = props.target
|
||||
void loadMonaco().catch(() => {})
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await previewReads.run<{ before: string; after: string; isBinary?: boolean; image?: GitImageDiff }>(request.signal, () => target.commit
|
||||
? serverApi.fetchGitCommitDiff(props.instanceId, target.slug, target.commit, target.path, request.signal)
|
||||
: serverApi.fetchWorktreeGitDiff(props.instanceId, target.slug, {
|
||||
path: target.path, originalPath: target.originalPath, scope: target.scope ?? "unstaged",
|
||||
}, request.signal), "visible")
|
||||
if (request.signal.aborted) return
|
||||
if (result.isBinary && !result.image) { setContent(null); setError(t("instanceShell.gitChanges.binaryViewer")) }
|
||||
else setContent(result)
|
||||
} catch (cause) {
|
||||
if (!request.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause))
|
||||
} finally {
|
||||
if (controller === request) {
|
||||
setLoading(false)
|
||||
if (pending) { pending = false; void load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
function cancel() { controller?.abort(); controller = undefined; pending = false; setLoading(false) }
|
||||
createEffect(on(() => props.target, () => { cancel(); setContent(null); setError(null); void load() }))
|
||||
createEffect(on(() => props.active, active => { if (!active) cancel(); else void load() }, { defer: true }))
|
||||
const refresh = createDebouncedRefresh(() => void load())
|
||||
createEffect(on(() => filesystemInvalidationVersion(props.instanceId), () => {
|
||||
if (props.active && !props.target.commit) refresh.trigger()
|
||||
}, { defer: true }))
|
||||
onCleanup(() => { cancel(); refresh.cancel() })
|
||||
|
||||
function insertContext(selection: { startLine: number; endLine: number }) {
|
||||
if (!props.active) return
|
||||
const revision = props.target.commit ? `Commit: ${props.target.commit} : ` : ""
|
||||
const directory = props.target.serviceDirectory ?? props.target.directory
|
||||
props.onInsertComment?.(`Git Diff: ${revision}Worktree: ${directory} : File: ${props.target.path} : ${selection.startLine}-${selection.endLine}`)
|
||||
}
|
||||
|
||||
return <section class="git-diff-view" aria-label={t("gitPanel.diff")}>
|
||||
<header class="window-header git-diff-header">
|
||||
<div class="git-diff-heading">
|
||||
<strong title={props.target.path}>{props.target.path}</strong>
|
||||
<span title={props.target.directory}>{props.target.slug} · {props.target.commit
|
||||
? `${props.target.commit.slice(0, 8)} · ${props.target.subject ?? ""} · ${t("gitPanel.parentDiff")}`
|
||||
: t(props.target.scope === "staged" ? "instanceShell.gitChanges.sections.staged" : "instanceShell.gitChanges.sections.unstaged")}</span>
|
||||
</div>
|
||||
<DiffToolbar viewMode={view()} contextMode={context()} onViewModeChange={setView} onContextModeChange={setContext} />
|
||||
<Show when={!content()?.image}><button class="files-header-icon-button icon-toggle" aria-label={t(wrap() ? "instanceShell.filesShell.disableWordWrap" : "instanceShell.filesShell.enableWordWrap")} aria-pressed={wrap()} onClick={() => setWrap(!wrap())}><WrapText size={16} /></button></Show>
|
||||
<button class="files-header-icon-button" aria-label={t("gitPanel.backChat")} title={t("gitPanel.backChat")} onClick={props.onClose}><X size={16} /></button>
|
||||
</header>
|
||||
<Show when={error()}><div class="p-3 text-error" role="alert">{error()} <button onClick={() => void load()}>{t("instanceShell.rightPanel.actions.refresh")}</button></div></Show>
|
||||
<Show when={loading() && !content()}><div class="p-3 text-secondary">{t("instanceInfo.loading")}</div></Show>
|
||||
<Show when={content()}>{value => <Show when={value().image} fallback={<div class="git-diff-content">
|
||||
<Suspense fallback={<div class="p-3">{t("instanceInfo.loading")}</div>}>
|
||||
<MonacoDiffViewer scopeKey={`${props.instanceId}:${props.target.slug}:${props.target.commit ?? props.target.scope}`} path={props.target.path}
|
||||
onRequestInsertContext={insertContext} insertContextLabel={t("instanceShell.gitChanges.actions.insertContext")}
|
||||
before={value().before} after={value().after} viewMode={view()} contextMode={context()} wordWrap={wrap() ? "on" : "off"} />
|
||||
</Suspense>
|
||||
</div>}>{image => <GitImagePreview image={image()} path={props.target.path} view={view()} />}</Show>}</Show>
|
||||
</section>
|
||||
}
|
||||
19
packages/ui/src/components/git-image-preview.tsx
Normal file
19
packages/ui/src/components/git-image-preview.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { For, Show, createEffect, createSignal, on } from "solid-js"
|
||||
import type { GitImageDiff } from "../../../server/src/git-history-types"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
export function GitImagePreview(props: { image: GitImageDiff; path: string; view: "split" | "unified" }) {
|
||||
const { t } = useI18n()
|
||||
return <div class="git-image-diff" data-view={props.view}>
|
||||
<For each={["before", "after"] as const}>{side => {
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
createEffect(on(() => props.image[side], () => setFailed(false)))
|
||||
return <Show when={props.image[side]}>{bytes => <figure class="git-image-version">
|
||||
<figcaption>{t(`filesPanel.image.${side}`)}</figcaption>
|
||||
<Show when={!failed()} fallback={<p class="p-3 text-secondary">{t("filesPanel.binary")}</p>}>
|
||||
<img src={`data:${props.image.mime};base64,${bytes()}`} alt={`${props.path} · ${t(`filesPanel.image.${side}`)}`} onError={() => setFailed(true)} />
|
||||
</Show>
|
||||
</figure>}</Show>
|
||||
}}</For>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { Component } from "solid-js"
|
||||
import { For, onMount } from "solid-js"
|
||||
import ActionOverflowMenu, { type ActionOverflowMenuItem } from "../../../action-overflow-menu"
|
||||
import { observeRowOverflow } from "./row-compact-actions"
|
||||
|
||||
// File row actions with the message/session convention: inline icon buttons
|
||||
// while they fit, the shared overflow menu only when they don't. Hidden
|
||||
// actions stay measurable but inert; an open menu stays mounted.
|
||||
const FileRowActions: Component<{ items: ActionOverflowMenuItem[]; label: string }> = props => {
|
||||
let element!: HTMLSpanElement
|
||||
onMount(() => observeRowOverflow(element))
|
||||
// Preview toggles remain directly reachable; only secondary actions overflow.
|
||||
const toggles = () => props.items.filter(item => typeof item.checked === "boolean")
|
||||
const secondary = () => props.items.filter(item => typeof item.checked !== "boolean")
|
||||
const button = (item: ActionOverflowMenuItem) => <button type="button" class="file-row-icon-button"
|
||||
classList={{ "icon-toggle": typeof item.checked === "boolean" }} title={item.label} aria-label={item.label} aria-pressed={item.checked}
|
||||
disabled={item.disabled} onClick={() => void item.onSelect()}>{item.icon}</button>
|
||||
return <span class="file-row-actions" data-compact="false" ref={element}
|
||||
onClick={event => event.stopPropagation()} onPointerDown={event => event.stopPropagation()}>
|
||||
<span class="file-row-pinned-actions"><For each={toggles()}>{button}</For></span>
|
||||
<span class="file-row-inline-actions">
|
||||
<For each={secondary()}>{button}</For>
|
||||
</span>
|
||||
<ActionOverflowMenu items={secondary()} label={props.label} triggerClass="file-row-icon-button action-overflow-trigger" />
|
||||
</span>
|
||||
}
|
||||
|
||||
export default FileRowActions
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
type RightPanelTabModule,
|
||||
} from "./registry"
|
||||
import { createCoreRightPanelRuntime } from "./core-runtime"
|
||||
import { FILES_PANEL_MIGRATION_KEY, mergeFilesPanelCustomization } from "./files-panel-state"
|
||||
import { loadRightPanelPluginManifests, type RightPanelPluginLoadError } from "./plugin-manifest"
|
||||
import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins"
|
||||
import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections"
|
||||
|
|
@ -93,15 +94,20 @@ interface RightPanelProps {
|
|||
}
|
||||
|
||||
const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("git-changes"))
|
||||
const savedTab = readStoredRightPanelTab("files")
|
||||
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(savedTab === "git-changes" ? "files" : savedTab)
|
||||
const defaultStatusSectionIds = CORE_STATUS_SECTION_ITEMS.map((section) => section.id)
|
||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(defaultStatusSectionIds)
|
||||
const [rightPanelCustomizationOpen, setRightPanelCustomizationOpen] = createSignal(false)
|
||||
let customizationTriggerRef: HTMLButtonElement | undefined
|
||||
let customizationPopoverRef: HTMLDivElement | undefined
|
||||
const [rightPanelCustomization, setRightPanelCustomization] = createSignal<RightPanelCustomization>(
|
||||
parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)),
|
||||
)
|
||||
let savedCustomization = parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY))
|
||||
if (!readClientLayoutValue(FILES_PANEL_MIGRATION_KEY)) {
|
||||
savedCustomization = mergeFilesPanelCustomization(savedCustomization)
|
||||
writeClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY, JSON.stringify(savedCustomization))
|
||||
writeClientLayoutValue(FILES_PANEL_MIGRATION_KEY, "1")
|
||||
}
|
||||
const [rightPanelCustomization, setRightPanelCustomization] = createSignal<RightPanelCustomization>(savedCustomization)
|
||||
const tabGroupId = `right-panel-${createUniqueId()}`
|
||||
const tabId = (id: string) => `${tabGroupId}-tab-${id}`
|
||||
const tabPanelId = (id: string) => `${tabGroupId}-panel-${id}`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Component } from "solid-js"
|
||||
|
||||
import { AlignJustify, FoldVertical, Split, UnfoldVertical } from "lucide-solid"
|
||||
import { Columns, FoldVertical, Rows, UnfoldVertical } from "lucide-solid"
|
||||
|
||||
import { useI18n } from "../../../../../lib/i18n"
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
|
|
@ -31,7 +31,7 @@ const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
|||
data-active={props.viewMode === "split" ? "true" : undefined}
|
||||
title={viewModeTitle()}
|
||||
>
|
||||
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
|
||||
{props.viewMode === "split" ? <Columns class="h-4 w-4" aria-hidden="true" /> : <Rows class="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
import { Show, type Component, type JSX } from "solid-js"
|
||||
|
||||
import { useI18n } from "../../../../../lib/i18n"
|
||||
import OverlayList from "./OverlayList"
|
||||
|
||||
type SplitFilePanelList = {
|
||||
panel: () => JSX.Element
|
||||
overlay: () => JSX.Element
|
||||
}
|
||||
|
||||
interface SplitFilePanelProps {
|
||||
header: JSX.Element
|
||||
list: SplitFilePanelList
|
||||
viewer: JSX.Element
|
||||
viewerActions?: JSX.Element
|
||||
|
||||
listOpen: boolean
|
||||
onToggleList: () => void
|
||||
|
||||
splitWidth: number
|
||||
onResizeMouseDown: (event: MouseEvent) => void
|
||||
onResizeTouchStart: (event: TouchEvent) => void
|
||||
|
||||
isPhoneLayout: boolean
|
||||
overlayAriaLabel: string
|
||||
}
|
||||
|
||||
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const viewer = () => (
|
||||
<div class="relative flex flex-1 min-h-0 min-w-0">
|
||||
{props.viewer}
|
||||
<Show when={props.viewerActions}>
|
||||
<div class="absolute bottom-2 left-2 z-40 flex items-center gap-1">{props.viewerActions}</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="files-tab-container">
|
||||
<div class="files-tab-header">
|
||||
<div class="files-tab-header-row">
|
||||
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
|
||||
{props.listOpen ? t("instanceShell.filesShell.hideFiles") : t("instanceShell.filesShell.showFiles")}
|
||||
</button>
|
||||
|
||||
{props.header}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="files-tab-body">
|
||||
<Show
|
||||
when={!props.isPhoneLayout && props.listOpen}
|
||||
fallback={viewer()}
|
||||
>
|
||||
<div class="files-split" style={{ "--files-pane-width": `${props.splitWidth}px` }}>
|
||||
<div class="file-list-panel">
|
||||
<div class="file-list-scroll">{props.list.panel()}</div>
|
||||
</div>
|
||||
<div
|
||||
class="file-split-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("instanceShell.filesShell.resizeFiles")}
|
||||
onMouseDown={props.onResizeMouseDown}
|
||||
onTouchStart={props.onResizeTouchStart}
|
||||
/>
|
||||
{viewer()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.isPhoneLayout}>
|
||||
<Show when={props.listOpen}>
|
||||
<OverlayList ariaLabel={props.overlayAriaLabel}>{props.list.overlay()}</OverlayList>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SplitFilePanel
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import { For, Show, createMemo } from "solid-js"
|
||||
import { ChevronDown, ChevronRight, Copy, ExternalLink, Eye, Folder, FolderOpen, FileCode, FileText, Image, File, RefreshCw, TerminalSquare } from "lucide-solid"
|
||||
import type { useWorkspaceTree } from "../useWorkspaceTree"
|
||||
import type { ActionOverflowMenuItem } from "../../../../action-overflow-menu"
|
||||
import FileRowActions from "../FileRowActions"
|
||||
import { copyToClipboard } from "../../../../../lib/clipboard"
|
||||
import { showToastNotification } from "../../../../../lib/notifications"
|
||||
import { canOpenWorkspacePaths, openWorkspacePath } from "../../../../../lib/workspace-open"
|
||||
|
||||
export function WorkspaceTree(props: {
|
||||
tree: ReturnType<typeof useWorkspaceTree>
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
directory: string
|
||||
instanceId: string
|
||||
worktreeSlug: string
|
||||
canOpen: boolean
|
||||
previewPath?: string
|
||||
onOpen: (path: string) => void
|
||||
}) {
|
||||
const icon = (name: string) => /\.(png|jpe?g|gif|webp|svg|ico|bmp|avif)$/i.test(name) ? Image
|
||||
: /\.(md|mdx|txt|rst)$/i.test(name) ? FileText
|
||||
: /\.(tsx?|jsx?|json|ya?ml|css|html|py|rs|sh)$/i.test(name) ? FileCode : File
|
||||
let root!: HTMLDivElement
|
||||
const tabStop = createMemo(() => props.tree.rows().some(row => row.path === props.tree.state().selected)
|
||||
? props.tree.state().selected : props.tree.rows()[0]?.path)
|
||||
const focusRow = (path: string) => root.querySelector<HTMLElement>(`[data-path="${CSS.escape(path)}"]`)?.focus()
|
||||
const activateRow = (row: ReturnType<typeof props.tree.rows>[number]) => {
|
||||
props.tree.select(row.path)
|
||||
if (row.type === "directory") props.tree.toggle(row.path)
|
||||
}
|
||||
function keyDown(event: KeyboardEvent, row: ReturnType<typeof props.tree.rows>[number]) {
|
||||
// The row menu trigger is a separate tab stop; its keys must not toggle the row.
|
||||
if (event.target !== event.currentTarget) return
|
||||
const rows = props.tree.rows(), index = rows.findIndex(entry => entry.path === row.path)
|
||||
const rtl = document.documentElement.dir === "rtl"
|
||||
if (event.key === "ArrowDown") focusRow(rows[Math.min(index + 1, rows.length - 1)]!.path)
|
||||
else if (event.key === "ArrowUp") focusRow(rows[Math.max(index - 1, 0)]!.path)
|
||||
else if (event.key === "Home") focusRow(rows[0]!.path)
|
||||
else if (event.key === "End") focusRow(rows[rows.length - 1]!.path)
|
||||
else if (event.key === (rtl ? "ArrowLeft" : "ArrowRight") && row.type === "directory") {
|
||||
if (!props.tree.state().expanded.has(row.path)) props.tree.toggle(row.path)
|
||||
else if (rows[index + 1]?.parent === row.path) focusRow(rows[index + 1]!.path)
|
||||
} else if (event.key === (rtl ? "ArrowRight" : "ArrowLeft")) {
|
||||
if (row.type === "directory" && props.tree.state().expanded.has(row.path)) props.tree.toggle(row.path)
|
||||
else focusRow(row.parent)
|
||||
} else if (event.key === "Enter" || event.key === " ") {
|
||||
activateRow(row)
|
||||
} else return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleCopyPath = async (path: string) => {
|
||||
const ok = await copyToClipboard(path)
|
||||
showToastNotification({
|
||||
message: ok ? props.t("instanceShell.filesShell.toast.copyPathSuccess") : props.t("instanceShell.filesShell.toast.copyPathError"),
|
||||
variant: ok ? "success" : "error",
|
||||
})
|
||||
}
|
||||
|
||||
const handleNativeOpen = async (target: "default" | "reveal" | "terminal", path: string) => {
|
||||
try {
|
||||
await openWorkspacePath({ target, instanceId: props.instanceId, worktreeSlug: props.worktreeSlug, path })
|
||||
} catch (error) {
|
||||
showToastNotification({
|
||||
message: props.t("instanceShell.filesShell.toast.openError", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rowActions = (row: ReturnType<typeof props.tree.rows>[number]): ActionOverflowMenuItem[] => {
|
||||
const items: ActionOverflowMenuItem[] = []
|
||||
if (row.type === "file") items.push({
|
||||
key: "preview", label: `${props.t("filesPanel.viewer")} · ${row.path}`,
|
||||
icon: <Eye class="w-3.5 h-3.5" />, disabled: !props.canOpen,
|
||||
checked: props.previewPath === row.path, onSelect: () => props.onOpen(row.path),
|
||||
})
|
||||
if (canOpenWorkspacePaths()) {
|
||||
if (row.type === "directory") {
|
||||
const isMacApp = isMacDesktop && row.path.toLowerCase().endsWith(".app")
|
||||
items.push(
|
||||
{
|
||||
key: "open-folder",
|
||||
label: props.t(isMacApp
|
||||
? "instanceShell.filesShell.actions.showInFolder"
|
||||
: "instanceShell.filesShell.actions.openFolder"),
|
||||
icon: <FolderOpen class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleNativeOpen(isMacApp ? "reveal" : "default", row.path),
|
||||
},
|
||||
{
|
||||
key: "open-terminal",
|
||||
label: props.t("instanceShell.filesShell.actions.openTerminal"),
|
||||
icon: <TerminalSquare class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleNativeOpen("terminal", row.path),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
if (isWindowsDesktop) {
|
||||
items.push({
|
||||
key: "open-default",
|
||||
label: props.t({
|
||||
open: "instanceShell.filesShell.actions.openDefault",
|
||||
edit: "instanceShell.filesShell.actions.editDefault",
|
||||
choose: "instanceShell.filesShell.actions.chooseApplication",
|
||||
}[windowsDefaultAction(row.path)]),
|
||||
icon: <ExternalLink class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleNativeOpen("default", row.path),
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
key: "show-in-folder",
|
||||
label: props.t("instanceShell.filesShell.actions.showInFolder"),
|
||||
icon: <FolderOpen class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleNativeOpen("reveal", row.path),
|
||||
})
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
key: "copy-path",
|
||||
label: props.t("instanceShell.filesShell.actions.copyPath"),
|
||||
icon: <Copy class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleCopyPath(row.path),
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
return <div class="workspace-tree">
|
||||
<div class="workspace-tree-root" title={props.directory}><FolderOpen size={15} /><strong>{props.directory.replace(/\\/g, "/").split("/").pop()}</strong></div>
|
||||
<div ref={root} role="tree" aria-label={props.t("filesPanel.workspace")}>
|
||||
<For each={props.tree.rows()}>{row => {
|
||||
const isFolder = row.type === "directory", Icon = icon(row.name)
|
||||
const expanded = () => props.tree.state().expanded.has(row.path)
|
||||
return <div role="treeitem" data-path={row.path} aria-label={row.name} aria-level={row.depth}
|
||||
aria-expanded={isFolder ? expanded() : undefined} aria-selected={props.tree.state().selected === row.path}
|
||||
class="workspace-tree-row" style={{ "padding-inline-start": `${8 + row.depth * 14}px` }} title={row.path}
|
||||
tabIndex={tabStop() === row.path ? 0 : -1}
|
||||
onFocus={() => props.tree.select(row.path)} onKeyDown={event => keyDown(event, row)}
|
||||
onClick={() => activateRow(row)}>
|
||||
<Show when={isFolder} fallback={<span class="workspace-tree-spacer" />}>
|
||||
<Show when={expanded()} fallback={<ChevronRight size={12} />}><ChevronDown size={12} /></Show>
|
||||
</Show>
|
||||
<Show when={isFolder} fallback={<Icon size={14} />}><Show when={expanded()} fallback={<Folder size={14} />}><FolderOpen size={14} /></Show></Show>
|
||||
<span class="workspace-tree-label">{row.name}</span><Show when={props.tree.busy().has(row.path)}><RefreshCw size={12} class="animate-spin" /></Show>
|
||||
<FileRowActions items={rowActions(row)} label={props.t("instanceShell.filesShell.actions.more", { name: row.name })} />
|
||||
</div>
|
||||
}}</For>
|
||||
</div>
|
||||
<For each={[...props.tree.errors()]}>{([path, error]) => <div class="p-3 text-xs text-error" role="alert">{path}: {error}
|
||||
<button onClick={() => void props.tree.load(path, true)}>{props.t("instanceShell.rightPanel.actions.refresh")}</button></div>}</For>
|
||||
<Show when={props.tree.busy().has(".")}><p class="p-3 text-xs text-secondary" role="status">{props.t("instanceInfo.loading")}</p></Show>
|
||||
</div>
|
||||
}
|
||||
|
||||
const isWindowsDesktop = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent)
|
||||
const isMacDesktop = typeof navigator !== "undefined" && /macintosh|mac os x/i.test(navigator.userAgent)
|
||||
const safeWindowsOpenExtensions = new Set([
|
||||
"7z", "avi", "bmp", "c", "cc", "cfg", "conf", "cpp", "cs", "css", "csv", "dart", "diff", "docx",
|
||||
"env", "flac", "fs", "fsx", "gif", "go", "gz", "h", "hpp", "htm", "html", "ini", "java", "jpeg", "jpg",
|
||||
"json", "jsonc", "jsx", "kt", "kts", "less", "lock", "log", "lua", "md", "markdown", "mkv", "mov", "mp3",
|
||||
"mp4", "ogg", "patch", "pdf", "png", "pptx", "r", "rar", "rmd", "rs", "scss", "sql", "svg", "svelte",
|
||||
"swift", "tar", "toml", "ts", "tsx", "txt", "vue", "wav", "webm", "webp", "xlsx", "xml", "yaml", "yml", "zip",
|
||||
])
|
||||
const windowsEditExtensions = new Set([
|
||||
"bat", "cmd", "js", "jse", "pl", "ps1", "psd1", "psm1", "py", "pyw", "rb", "reg", "vbe", "vbs", "wsf", "wsh",
|
||||
])
|
||||
|
||||
function windowsDefaultAction(path: string): "open" | "edit" | "choose" {
|
||||
const name = path.split(/[\\/]/).pop() ?? ""
|
||||
const dot = name.lastIndexOf(".")
|
||||
const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : ""
|
||||
if (!isWindowsDesktop || (extension && safeWindowsOpenExtensions.has(extension))) return "open"
|
||||
if (extension && windowsEditExtensions.has(extension)) return "edit"
|
||||
return "choose"
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import type { RightPanelModule } from "./registry"
|
|||
import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections"
|
||||
|
||||
interface CoreRightPanelRenderers {
|
||||
renderGitChangesTab: () => JSX.Element
|
||||
renderFilesTab: () => JSX.Element
|
||||
renderStatusTab: () => JSX.Element
|
||||
}
|
||||
|
|
@ -31,12 +30,6 @@ export function createCoreRightPanelManifest(renderers: CoreRightPanelRenderers)
|
|||
descriptionKey: "instanceShell.rightPanel.modules.core.description",
|
||||
origin: "first-party",
|
||||
tabs: [
|
||||
{
|
||||
id: "git-changes",
|
||||
labelKey: "instanceShell.rightPanel.tabs.gitChanges",
|
||||
order: 10,
|
||||
render: renderers.renderGitChangesTab,
|
||||
},
|
||||
{
|
||||
id: "files",
|
||||
labelKey: "instanceShell.rightPanel.tabs.files",
|
||||
|
|
|
|||
|
|
@ -1,38 +1,21 @@
|
|||
import { createEffect, createMemo, createSignal, lazy, type Accessor } from "solid-js"
|
||||
|
||||
import { createEffect, createMemo, createSignal, lazy, on, onCleanup, type Accessor } from "solid-js"
|
||||
import type { Instance } from "../../../../types/instance"
|
||||
import type { Session } from "../../../../types/session"
|
||||
import type { PromptInputApi } from "../../../prompt-input/types"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
|
||||
import type { RightPanelTab } from "./types"
|
||||
import type { RightPanelCustomization, RightPanelSectionModule } from "./registry"
|
||||
|
||||
import {
|
||||
getDefaultWorktreeSlug,
|
||||
getGitRepoStatus,
|
||||
getWorktreeSlugForSession,
|
||||
getWorktrees,
|
||||
} from "../../../../stores/worktrees"
|
||||
import { writeClientLayoutValue } from "../../../../stores/client-state"
|
||||
import {
|
||||
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
||||
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
||||
RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY,
|
||||
readStoredBool,
|
||||
readStoredEnum,
|
||||
} from "../storage"
|
||||
import { getDefaultWorktreeSlug, getWorktreeSlugForSession, getWorktrees, getGitRepoStatus } from "../../../../stores/worktrees"
|
||||
import { closeFilePreview, openFilePreview, type FilePreviewTarget } from "../../../../stores/files-preview"
|
||||
import { showSessionChat } from "../../../../stores/session-previews"
|
||||
import { useGitChanges } from "./useGitChanges"
|
||||
import { useGitHistory } from "./useGitHistory"
|
||||
import { createCoreRightPanelManifest } from "./core-plugin"
|
||||
import { createFilesTabRuntime } from "./tabs/files-runtime"
|
||||
import { createSplitResize } from "./tabs/split-resize"
|
||||
import { useWorkspaceTree } from "./useWorkspaceTree"
|
||||
import { FILES_PANEL_MODE_KEY, type FilesPanelMode } from "./files-panel-state"
|
||||
import { readStoredEnum } from "../storage"
|
||||
import { writeClientLayoutValue } from "../../../../stores/client-state"
|
||||
|
||||
const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab"))
|
||||
const LazyFilesPanel = lazy(() => import("./tabs/FilesPanel"))
|
||||
const LazyStatusTab = lazy(() => import("./tabs/StatusTab"))
|
||||
|
||||
interface CoreRightPanelRuntimeOptions {
|
||||
|
|
@ -55,181 +38,54 @@ interface CoreRightPanelRuntimeOptions {
|
|||
}
|
||||
|
||||
export function createCoreRightPanelRuntime(options: CoreRightPanelRuntimeOptions) {
|
||||
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
|
||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
|
||||
)
|
||||
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
|
||||
)
|
||||
const [diffWordWrapMode, setDiffWordWrapMode] = createSignal<DiffWordWrapMode>(
|
||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
|
||||
)
|
||||
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
||||
const [gitStagedOpen, setGitStagedOpen] = createSignal(true)
|
||||
const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true)
|
||||
|
||||
const listLayoutKey = createMemo(() => (options.isPhoneLayout() ? "phone" : "nonphone"))
|
||||
|
||||
const gitListOpenStorageKey = createMemo(() =>
|
||||
listLayoutKey() === "phone" ? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||
)
|
||||
|
||||
const gitSectionStorageKey = (section: "staged" | "unstaged") => {
|
||||
const phone = listLayoutKey() === "phone"
|
||||
if (section === "staged") {
|
||||
return phone ? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY
|
||||
}
|
||||
return phone ? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
gitListOpenStorageKey()
|
||||
const gitPersisted = readStoredBool(gitListOpenStorageKey())
|
||||
if (gitPersisted !== null) {
|
||||
setGitChangesListOpen(gitPersisted)
|
||||
} else {
|
||||
setGitChangesListOpen(true)
|
||||
}
|
||||
|
||||
setGitStagedOpen(readStoredBool(gitSectionStorageKey("staged")) ?? true)
|
||||
setGitUnstagedOpen(readStoredBool(gitSectionStorageKey("unstaged")) ?? true)
|
||||
})
|
||||
|
||||
createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode()))
|
||||
createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode()))
|
||||
createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode()))
|
||||
|
||||
const gitChangesSplit = createSplitResize({
|
||||
storageKey: RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
|
||||
defaultWidth: 320,
|
||||
rightDrawerWidth: options.rightDrawerWidth,
|
||||
rightDrawerWidthInitialized: options.rightDrawerWidthInitialized,
|
||||
})
|
||||
|
||||
const worktreeSlugForViewer = createMemo(() => {
|
||||
const [mode, setMode] = createSignal<FilesPanelMode>(readStoredEnum(FILES_PANEL_MODE_KEY, ["workspace", "changes", "history"] as const) ?? "workspace")
|
||||
createEffect(() => writeClientLayoutValue(FILES_PANEL_MODE_KEY, mode()))
|
||||
const [browsedWorktree, setBrowsedWorktree] = createSignal<string | null>(null)
|
||||
const sessionWorktree = createMemo(() => {
|
||||
const sessionId = options.activeSessionId()
|
||||
if (sessionId && sessionId !== "info") {
|
||||
return getWorktreeSlugForSession(options.instanceId, sessionId)
|
||||
return sessionId && sessionId !== "info" ? getWorktreeSlugForSession(options.instanceId, sessionId) : getDefaultWorktreeSlug(options.instanceId)
|
||||
})
|
||||
createEffect(on(() => `${options.activeSessionId()}:${sessionWorktree()}`, () => {
|
||||
setBrowsedWorktree(null)
|
||||
closeFilePreview(options.instanceId)
|
||||
}))
|
||||
const slug = createMemo(() => browsedWorktree() ?? sessionWorktree())
|
||||
const worktrees = createMemo(() => getWorktrees(options.instanceId))
|
||||
const worktree = createMemo(() => worktrees().find(entry => entry.slug === slug()))
|
||||
createEffect(() => {
|
||||
// A removed browsed worktree must never silently turn into a root-file read.
|
||||
if (browsedWorktree() && worktrees().length && !worktree()) {
|
||||
setBrowsedWorktree(null)
|
||||
closeFilePreview(options.instanceId)
|
||||
}
|
||||
return getDefaultWorktreeSlug(options.instanceId)
|
||||
})
|
||||
|
||||
const gitChangesWorktreeSlug = createMemo(() => {
|
||||
if (getGitRepoStatus(options.instanceId) === false) return null
|
||||
const slug = worktreeSlugForViewer().trim()
|
||||
return slug ? slug : null
|
||||
})
|
||||
|
||||
const gitChangesWorktree = createMemo(() => {
|
||||
const slug = gitChangesWorktreeSlug()
|
||||
if (!slug) return null
|
||||
return getWorktrees(options.instanceId).find((worktree) => worktree.slug === slug) ?? null
|
||||
})
|
||||
|
||||
const gitChangesBranchLabel = createMemo(() => gitChangesWorktree()?.branch?.trim() || null)
|
||||
const gitScopeKey = createMemo(() => `${options.instanceId}:git:${worktreeSlugForViewer()}`)
|
||||
const active = () => options.isActive() && options.rightPanelTab() === "files"
|
||||
const gitAvailable = () => getGitRepoStatus(options.instanceId) !== false
|
||||
createEffect(() => { if (!gitAvailable()) setMode("workspace") })
|
||||
const directory = () => worktree()?.directory ?? options.instance.folder
|
||||
const tree = useWorkspaceTree(options.instanceId, directory, () => active() && mode() === "workspace")
|
||||
const history = useGitHistory(options.instanceId, slug, () => active() && gitAvailable() && mode() === "history")
|
||||
const git = useGitChanges({
|
||||
isActive: options.isActive,
|
||||
t: options.t,
|
||||
instanceId: options.instanceId,
|
||||
rightPanelTab: options.rightPanelTab,
|
||||
worktreeSlug: worktreeSlugForViewer,
|
||||
isPhoneLayout: options.isPhoneLayout,
|
||||
promptInputApi: options.promptInputApi,
|
||||
closeGitList: () => setGitChangesListOpen(false),
|
||||
...options, isActive: () => active() && gitAvailable() && mode() === "changes", rightPanelTab: () => "git-changes",
|
||||
worktreeSlug: slug, closeGitList: () => {}, externalDiff: true,
|
||||
})
|
||||
const renderFilesTab = createFilesTabRuntime({
|
||||
t: options.t,
|
||||
instanceId: options.instanceId,
|
||||
rightPanelTab: options.rightPanelTab,
|
||||
worktreeSlug: worktreeSlugForViewer,
|
||||
isPhoneLayout: options.isPhoneLayout,
|
||||
rightDrawerWidth: options.rightDrawerWidth,
|
||||
rightDrawerWidthInitialized: options.rightDrawerWidthInitialized,
|
||||
})
|
||||
|
||||
const persistGitListOpen = (value: boolean) => {
|
||||
writeClientLayoutValue(gitListOpenStorageKey(), value ? "true" : "false")
|
||||
const openFile = (file: Pick<FilePreviewTarget, "kind" | "path" | "originalPath" | "scope" | "commit" | "subject">) => {
|
||||
const sessionId = options.activeSessionId()
|
||||
if (!sessionId || sessionId === "info") return
|
||||
showSessionChat(options.instance.folder)
|
||||
openFilePreview(options.instanceId, { ...file, sessionId, slug: slug(), directory: directory(), serviceDirectory: worktree()?.serviceDirectory })
|
||||
}
|
||||
|
||||
const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => {
|
||||
writeClientLayoutValue(gitSectionStorageKey(section), value ? "true" : "false")
|
||||
}
|
||||
|
||||
const toggleGitList = () => {
|
||||
setGitChangesListOpen((current) => {
|
||||
const next = !current
|
||||
persistGitListOpen(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(() => closeFilePreview(options.instanceId))
|
||||
return createCoreRightPanelManifest({
|
||||
renderGitChangesTab: () => (
|
||||
<LazyGitChangesTab
|
||||
t={options.t}
|
||||
activeSessionId={options.activeSessionId}
|
||||
entries={git.gitStatusEntries}
|
||||
statusLoading={git.gitStatusLoading}
|
||||
statusError={git.gitStatusError}
|
||||
selectedItemId={git.gitSelectedItemId}
|
||||
selectedBulkItemIds={git.gitBulkSelectedItemIds}
|
||||
selectedLoading={git.gitSelectedLoading}
|
||||
selectedError={git.gitSelectedError}
|
||||
selectedBefore={git.gitSelectedBefore}
|
||||
selectedAfter={git.gitSelectedAfter}
|
||||
mostChangedItemId={git.gitMostChangedItemId}
|
||||
scopeKey={gitScopeKey}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
onRowClick={git.handleGitRowClick}
|
||||
onRefresh={() => void git.refreshGitStatus()}
|
||||
onInsertContext={git.insertGitChangeContext}
|
||||
onStageFile={git.stageGitFile}
|
||||
onUnstageFile={git.unstageGitFile}
|
||||
commitMessage={git.gitCommitMessage}
|
||||
commitSubmitting={git.gitCommitSubmitting}
|
||||
onCommitMessageInput={git.setGitCommitMessage}
|
||||
onSubmitCommit={() => void git.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={gitChangesSplit.splitWidth}
|
||||
onResizeMouseDown={gitChangesSplit.onResizeMouseDown}
|
||||
onResizeTouchStart={gitChangesSplit.onResizeTouchStart}
|
||||
isPhoneLayout={options.isPhoneLayout}
|
||||
/>
|
||||
),
|
||||
renderFilesTab,
|
||||
renderStatusTab: () => (
|
||||
<LazyStatusTab
|
||||
t={options.t}
|
||||
instanceId={options.instanceId}
|
||||
instance={options.instance}
|
||||
activeSession={options.activeSession}
|
||||
isActive={() => options.isActive() && options.rightPanelTab() === "status"}
|
||||
expandedItems={options.expandedItems}
|
||||
onExpandedItemsChange={options.onExpandedItemsChange}
|
||||
customization={options.customization}
|
||||
onCustomizationChange={options.onCustomizationChange}
|
||||
extraSections={options.extraStatusSections()}
|
||||
/>
|
||||
),
|
||||
renderFilesTab: () => <LazyFilesPanel t={options.t} git={git} history={history} tree={tree} gitAvailable={gitAvailable()} mode={mode()} onModeChange={setMode}
|
||||
instanceId={options.instanceId} worktrees={worktrees()} slug={slug()} directory={worktree()?.directory ?? options.instance.folder}
|
||||
branch={worktree()?.branch ?? null} onWorktreeChange={value => {
|
||||
setBrowsedWorktree(value)
|
||||
closeFilePreview(options.instanceId)
|
||||
}} onOpenFile={openFile} canOpenFile={Boolean(options.activeSessionId() && options.activeSessionId() !== "info")} />,
|
||||
renderStatusTab: () => <LazyStatusTab t={options.t} instanceId={options.instanceId} instance={options.instance}
|
||||
activeSession={options.activeSession} isActive={() => options.isActive() && options.rightPanelTab() === "status"}
|
||||
expandedItems={options.expandedItems} onExpandedItemsChange={options.onExpandedItemsChange}
|
||||
customization={options.customization} onCustomizationChange={options.onCustomizationChange} extraSections={options.extraStatusSections()} />,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { mergeFilesPanelCustomization } from "./files-panel-state"
|
||||
|
||||
test("merging Git and Files preserves order and keeps either visible entry accessible", () => {
|
||||
const state = { tabOrder: ["status", "git-changes", "files"], hiddenTabIds: ["files"], statusSectionOrder: ["tokens"], hiddenStatusSectionIds: [] }
|
||||
assert.deepEqual(mergeFilesPanelCustomization(state), { ...state, tabOrder: ["status", "files"], hiddenTabIds: [] })
|
||||
assert.deepEqual(mergeFilesPanelCustomization({ ...state, hiddenTabIds: ["files", "git-changes"] }).hiddenTabIds, ["files"])
|
||||
assert.deepEqual(mergeFilesPanelCustomization({ ...state, tabOrder: ["files", "status", "git-changes"] }).tabOrder, ["files", "status"])
|
||||
})
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import type { RightPanelCustomization } from "./registry"
|
||||
|
||||
export const FILES_PANEL_MIGRATION_KEY = "opencode-files-panel-migration-v1"
|
||||
export const FILES_PANEL_MODE_KEY = "opencode-files-panel-mode-v1"
|
||||
export type FilesPanelMode = "workspace" | "changes" | "history"
|
||||
|
||||
// Called once per client layout. Either previously visible entry keeps the
|
||||
// merged Files surface available; retain the earliest position in the tab strip.
|
||||
export function mergeFilesPanelCustomization(value: RightPanelCustomization): RightPanelCustomization {
|
||||
const hidden = value.hiddenTabIds.filter(id => id !== "git-changes" && id !== "files")
|
||||
if (value.hiddenTabIds.includes("files") && value.hiddenTabIds.includes("git-changes")) hidden.push("files")
|
||||
return { ...value, hiddenTabIds: hidden,
|
||||
tabOrder: [...new Set(value.tabOrder.map(id => id === "git-changes" ? "files" : id))] }
|
||||
}
|
||||
|
|
@ -62,7 +62,6 @@ describe("right panel plugin manifests", () => {
|
|||
it("defines core right panel tabs and status sections as manifests", () => {
|
||||
const render = () => undefined as any
|
||||
const rightPanel = createCoreRightPanelManifest({
|
||||
renderGitChangesTab: render,
|
||||
renderFilesTab: render,
|
||||
renderStatusTab: render,
|
||||
})
|
||||
|
|
@ -77,7 +76,7 @@ describe("right panel plugin manifests", () => {
|
|||
|
||||
const rightPanelModule = rightPanel.create(host)
|
||||
|
||||
assert.deepEqual(rightPanelModule.tabs?.map((entry) => entry.id), ["git-changes", "files", "status"])
|
||||
assert.deepEqual(rightPanelModule.tabs?.map((entry) => entry.id), ["files", "status"])
|
||||
assert.equal(rightPanelModule.tabs?.find((entry) => entry.id === "status")?.alwaysVisible, true)
|
||||
assert.deepEqual(statusSections.statusSections?.map((entry) => entry.id), [
|
||||
"tokens",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { onCleanup } from "solid-js"
|
||||
|
||||
const px = (value: string) => Number.parseFloat(value) || 0
|
||||
|
||||
type Measurement = () => (() => void) | undefined
|
||||
const pendingMeasurements = new Set<Measurement>()
|
||||
let scheduledFrame = 0
|
||||
function scheduleMeasurement(measure: Measurement) {
|
||||
pendingMeasurements.add(measure)
|
||||
if (scheduledFrame) return
|
||||
scheduledFrame = requestAnimationFrame(() => {
|
||||
scheduledFrame = 0
|
||||
const pending = [...pendingMeasurements]
|
||||
pendingMeasurements.clear()
|
||||
// Read every row before changing any DOM. Per-row read/write callbacks
|
||||
// repeatedly laid out the entire inventory when thousands of files mounted.
|
||||
const updates = pending.map(read => read())
|
||||
for (const update of updates) update?.()
|
||||
})
|
||||
}
|
||||
|
||||
// Width of the row content ignoring the overflow trigger, so hidden inline
|
||||
// actions stay measurable. Text nodes are measured with a Range because the
|
||||
// label truncates with ellipsis.
|
||||
function rowContentWidth(element: Element): number {
|
||||
if (element.matches(".action-overflow-trigger")) return 0
|
||||
const style = getComputedStyle(element)
|
||||
if (style.display === "none") return 0
|
||||
if (element instanceof SVGElement || element.matches("button:not(.git-panel-file-main), .workspace-tree-spacer")) {
|
||||
return element.getBoundingClientRect().width
|
||||
}
|
||||
const widths: number[] = []
|
||||
for (const child of element.childNodes) {
|
||||
if (child instanceof Element) widths.push(rowContentWidth(child))
|
||||
else if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) {
|
||||
const range = document.createRange()
|
||||
range.selectNode(child)
|
||||
widths.push(range.getBoundingClientRect().width)
|
||||
}
|
||||
}
|
||||
const visible = widths.filter((width) => width > 0)
|
||||
const stacked = style.flexDirection === "column"
|
||||
return (stacked ? Math.max(0, ...visible) : visible.reduce((a, b) => a + b, 0))
|
||||
+ (stacked ? 0 : Math.max(0, visible.length - 1) * px(style.columnGap))
|
||||
+ px(style.paddingLeft) + px(style.paddingRight) + px(style.borderLeftWidth) + px(style.borderRightWidth)
|
||||
}
|
||||
|
||||
// Toggle data-compact on a file row: inline icon buttons while they fit, the
|
||||
// shared overflow menu only when they don't. An open menu stays mounted
|
||||
// across resizes; hidden actions stay measurable but inert.
|
||||
export function observeRowOverflow(element: HTMLElement) {
|
||||
const row = element.parentElement
|
||||
if (!row) return
|
||||
let disposed = false
|
||||
const measure = () => {
|
||||
if (disposed || !element.isConnected || !row.getBoundingClientRect().width) return
|
||||
const menu = element.querySelector<HTMLButtonElement>(".action-overflow-trigger")
|
||||
const inline = element.querySelector<HTMLElement>(".file-row-inline-actions")
|
||||
if (!menu || !inline) return
|
||||
// Budget against the entire row, not the shrink-wrapped action group.
|
||||
// The menu replaces inline actions; it must not add to their width budget.
|
||||
const required = rowContentWidth(row)
|
||||
const available = row.getBoundingClientRect().width
|
||||
const next = menu.hasAttribute("data-expanded") || required > available + 0.5
|
||||
if (element.dataset.compact === String(next)) return
|
||||
const active = document.activeElement
|
||||
const hadActionFocus = active instanceof Element && element.contains(active)
|
||||
&& active.matches(".file-row-inline-actions button, .action-overflow-trigger")
|
||||
return () => {
|
||||
if (disposed || !element.isConnected) return
|
||||
element.dataset.compact = String(next)
|
||||
inline.inert = next
|
||||
if (hadActionFocus) queueMicrotask(() => {
|
||||
if (disposed || !element.isConnected) return
|
||||
const target = next ? menu : element.querySelector<HTMLButtonElement>(".file-row-inline-actions button:not(:disabled)")
|
||||
target?.focus({ preventScroll: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
const schedule = () => { if (!disposed) scheduleMeasurement(measure) }
|
||||
const resize = new ResizeObserver(schedule)
|
||||
resize.observe(row)
|
||||
resize.observe(element)
|
||||
const mutation = new MutationObserver(schedule)
|
||||
mutation.observe(row, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["data-expanded"] })
|
||||
document.fonts?.addEventListener("loadingdone", schedule)
|
||||
schedule()
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
pendingMeasurements.delete(measure)
|
||||
if (!pendingMeasurements.size) { cancelAnimationFrame(scheduledFrame); scheduledFrame = 0 }
|
||||
resize.disconnect()
|
||||
mutation.disconnect()
|
||||
document.fonts?.removeEventListener("loadingdone", schedule)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
import { For, Show, createMemo, createSignal, createUniqueId, type Component, type JSX } from "solid-js"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
closestCenter,
|
||||
createDraggable,
|
||||
createDroppable,
|
||||
transformStyle,
|
||||
type DragEvent as SolidDndDragEvent,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import { ArrowLeft, ChevronRight, Eye, GitBranch, GitCommitHorizontal, Minus, Plus, RefreshCw } from "lucide-solid"
|
||||
import type { useGitChanges } from "../useGitChanges"
|
||||
import type { useGitHistory } from "../useGitHistory"
|
||||
import { closeFilePreview, getFilePreview, type FilePreviewTarget } from "../../../../../stores/files-preview"
|
||||
import FileRowActions from "../FileRowActions"
|
||||
import type { ActionOverflowMenuItem } from "../../../../action-overflow-menu"
|
||||
import { buildGitChangeListItems } from "../git-changes-model"
|
||||
import type { GitChangeListItem, GitChangeSection } from "../types"
|
||||
import type { useWorkspaceTree } from "../useWorkspaceTree"
|
||||
import { WorkspaceTree } from "../components/WorkspaceTree"
|
||||
import type { FilesPanelMode } from "../files-panel-state"
|
||||
|
||||
interface FilesPanelProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
git: ReturnType<typeof useGitChanges>
|
||||
history: ReturnType<typeof useGitHistory>
|
||||
tree: ReturnType<typeof useWorkspaceTree>
|
||||
gitAvailable: boolean
|
||||
mode: FilesPanelMode
|
||||
onModeChange: (mode: FilesPanelMode) => void
|
||||
worktrees: Array<{ slug: string; directory: string; branch?: string | null }>
|
||||
slug: string
|
||||
directory: string
|
||||
instanceId: string
|
||||
branch: string | null
|
||||
onWorktreeChange: (slug: string) => void
|
||||
onOpenFile: (file: Pick<FilePreviewTarget, "kind" | "path" | "originalPath" | "scope" | "commit" | "subject">) => void
|
||||
canOpenFile: boolean
|
||||
}
|
||||
|
||||
const FilesPanel: Component<FilesPanelProps> = props => {
|
||||
const items = createMemo(() => buildGitChangeListItems(props.git.gitStatusEntries()))
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [selectedCommitFile, setSelectedCommitFile] = createSignal("")
|
||||
const previewActive = (file: Parameters<FilesPanelProps["onOpenFile"]>[0]) => {
|
||||
const target = getFilePreview(props.instanceId)
|
||||
return Boolean(target && target.slug === props.slug && target.directory === props.directory && target.path === file.path
|
||||
&& (target.kind ?? "diff") === (file.kind ?? "diff") && target.commit === file.commit && target.scope === file.scope)
|
||||
}
|
||||
const togglePreview: FilesPanelProps["onOpenFile"] = file => {
|
||||
if (previewActive(file)) closeFilePreview(props.instanceId)
|
||||
else props.onOpenFile(file)
|
||||
}
|
||||
let historyBody: HTMLDivElement | undefined
|
||||
let historyScroll = 0
|
||||
const selectCommit = (id: string) => {
|
||||
historyScroll = historyBody?.scrollTop ?? 0
|
||||
void props.history.select(id)
|
||||
}
|
||||
const backToCommits = () => {
|
||||
props.history.back()
|
||||
requestAnimationFrame(() => { if (historyBody?.isConnected) historyBody.scrollTop = historyScroll })
|
||||
}
|
||||
const commits = createMemo(() => (props.history.page()?.commits ?? []).filter(commit =>
|
||||
`${commit.subject} ${commit.author} ${commit.id}`.toLowerCase().includes(filter().toLowerCase())))
|
||||
const handleStageDragEnd = ({ draggable, droppable }: SolidDndDragEvent) => {
|
||||
if (!droppable) return
|
||||
const target = String(droppable.id)
|
||||
if (target !== "staged" && target !== "unstaged") return
|
||||
const item = items().find(entry => entry.id === String(draggable.id))
|
||||
if (!item || item.section === target) return
|
||||
if (target === "staged") props.git.stageGitFile(item)
|
||||
else props.git.unstageGitFile(item)
|
||||
}
|
||||
const loading = () => props.mode === "workspace" ? props.tree.busy().size > 0 : props.mode === "history" ? props.history.loading() || props.history.detailLoading() : props.git.gitStatusLoading()
|
||||
const error = () => props.mode === "workspace" ? null : props.mode === "history" ? props.history.error() : props.git.gitStatusError()
|
||||
const basename = (path: string) => path.replace(/\\/g, "/").split("/").pop() || path
|
||||
const date = (value: string) => new Date(value).toLocaleDateString(document.documentElement.lang || undefined, { month: "short", day: "numeric" })
|
||||
|
||||
return <section class="git-panel" aria-label={props.t("instanceShell.rightPanel.tabs.files")}>
|
||||
<header class="git-panel-context">
|
||||
<div class="git-panel-branch"><GitBranch size={16} /><strong>{props.gitAvailable ? props.branch || props.t("gitPanel.detached") : basename(props.directory)}</strong>
|
||||
<button class="files-header-icon-button" aria-label={props.t("instanceShell.rightPanel.actions.refresh")} disabled={loading()} onClick={() => {
|
||||
if (props.mode === "workspace") props.tree.refresh()
|
||||
else if (props.mode === "history") void props.history.refresh()
|
||||
else void props.git.refreshGitStatus()
|
||||
}}><RefreshCw size={14} class={loading() ? "animate-spin" : ""} /></button>
|
||||
</div>
|
||||
<select class="git-panel-worktree" value={props.slug} aria-label={props.t("gitPanel.worktree")} title={props.directory}
|
||||
onChange={event => props.onWorktreeChange(event.currentTarget.value)}>
|
||||
<Show when={!props.worktrees.some(tree => tree.slug === props.slug)}><option value={props.slug}>{basename(props.directory)}</option></Show>
|
||||
<For each={props.worktrees}>{tree => <option value={tree.slug}>{basename(tree.directory)} · {tree.branch || props.t("gitPanel.detached")}</option>}</For>
|
||||
</select>
|
||||
</header>
|
||||
<div class="git-panel-tools">
|
||||
<div class="git-panel-switch" role="group" aria-label={props.t("instanceShell.rightPanel.tabs.files")}>
|
||||
<button aria-pressed={props.mode === "workspace"} onClick={() => props.onModeChange("workspace")}>{props.t("filesPanel.workspace")}</button>
|
||||
<button disabled={!props.gitAvailable} aria-pressed={props.mode === "changes"} onClick={() => props.onModeChange("changes")}>{props.t("gitPanel.changes")}</button>
|
||||
<button disabled={!props.gitAvailable} aria-pressed={props.mode === "history"} onClick={() => props.onModeChange("history")}>{props.t("filesPanel.commits")}</button>
|
||||
</div>
|
||||
<Show when={props.mode === "changes" && props.git.gitStatusEntries()}>
|
||||
<span class="git-panel-count">{props.t("gitPanel.files", { count: props.git.gitStatusEntries()?.length ?? 0 })}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={error()}><div role="alert" class="p-3 text-error text-xs">{error()}</div></Show>
|
||||
<Show when={!props.canOpenFile}><p class="p-3 text-xs text-secondary">{props.t("instanceShell.gitChanges.noSessionSelected")}</p></Show>
|
||||
<div class="git-panel-body" style={{ display: props.mode === "workspace" ? undefined : "none" }}>
|
||||
<WorkspaceTree tree={props.tree} t={props.t} directory={props.directory} instanceId={props.instanceId} worktreeSlug={props.slug} canOpen={props.canOpenFile}
|
||||
previewPath={getFilePreview(props.instanceId)?.kind === "workspace" && getFilePreview(props.instanceId)?.slug === props.slug ? getFilePreview(props.instanceId)?.path : undefined}
|
||||
onOpen={path => togglePreview({ kind: "workspace", path })} />
|
||||
</div>
|
||||
<div ref={historyBody} class="git-panel-body" style={{ display: props.mode === "history" ? undefined : "none" }}>
|
||||
<div style={{ display: props.history.selected() ? "none" : undefined }}>
|
||||
<input class="git-panel-filter" aria-label={props.t("gitPanel.filter")} placeholder={props.t("gitPanel.filter")} value={filter()} onInput={event => setFilter(event.currentTarget.value)} />
|
||||
<For each={commits()}>{commit => <button class="git-history-row" onClick={() => selectCommit(commit.id)}>
|
||||
<GitCommitHorizontal class="git-history-mark" size={16} />
|
||||
<span class="git-history-row-content"><strong>{commit.subject}</strong>
|
||||
<Show when={commit.refs}><span class="git-history-refs" title={commit.refs}>{commit.refs}</span></Show>
|
||||
<span class="git-history-meta"><span>{commit.author} · {date(commit.date)}</span><code>{commit.id.slice(0, 7)}</code></span>
|
||||
</span>
|
||||
</button>}</For>
|
||||
<Show when={!loading() && commits().length === 0}><p class="p-3 text-xs text-secondary">{props.t("gitPanel.emptyHistory")}</p></Show>
|
||||
<Show when={props.history.page()?.hasMore}><button class="git-panel-more" disabled={loading()} onClick={() => void props.history.refresh(true)}>{props.t("gitPanel.more")}</button></Show>
|
||||
</div>
|
||||
<Show when={props.history.selected()}>
|
||||
<button class="git-panel-back" onClick={backToCommits}><ArrowLeft size={14} />{props.t("filesPanel.commits")}</button>
|
||||
<Show when={props.history.details()}>{details => <>
|
||||
<div class="git-commit-summary"><code>{details().id.slice(0, 8)}</code><p>{details().message}</p><span>{props.t("gitPanel.files", { count: details().files.length })}</span></div>
|
||||
<div class="git-commit-files">
|
||||
<For each={details().files}>{file => {
|
||||
const target = () => ({ path: file.path, commit: details().id, subject: details().message.split("\n")[0] })
|
||||
return <div class="git-panel-file-row" classList={{ "git-panel-file-selected": selectedCommitFile() === `${details().id}:${file.path}` }}>
|
||||
<button class="git-panel-file git-panel-file-main" title={file.originalPath ? `${file.originalPath} → ${file.path}` : file.path} onClick={() => setSelectedCommitFile(`${details().id}:${file.path}`)}>
|
||||
<span class={`git-file-status git-file-status-${file.status}`}>{file.status}</span><span>{file.path}</span>
|
||||
</button>
|
||||
<FileRowActions label={props.t("instanceShell.filesShell.actions.more", { name: file.path })} items={[{
|
||||
key: "preview", label: `${props.t("filesPanel.viewer")} · ${file.path}`, icon: <Eye size={14} />,
|
||||
checked: previewActive(target()), disabled: !props.canOpenFile, onSelect: () => togglePreview(target()),
|
||||
}]} />
|
||||
</div>
|
||||
}}</For>
|
||||
</div>
|
||||
</>}</Show>
|
||||
</Show>
|
||||
<Show when={loading()}><p class="p-3 text-xs text-secondary" role="status">{props.t("instanceInfo.loading")}</p></Show>
|
||||
</div>
|
||||
<div class="git-panel-body" style={{ display: props.mode === "changes" ? undefined : "none" }}>
|
||||
<DragDropProvider collisionDetector={closestCenter} onDragEnd={handleStageDragEnd}>
|
||||
<DragDropSensors>
|
||||
<Show when={items().length === 0}><p class="p-3 text-xs text-secondary">{props.git.gitStatusLoading() ? props.t("instanceInfo.loading") : props.t("instanceShell.gitChanges.empty")}</p></Show>
|
||||
<For each={["staged", "unstaged"] as const}>{section =>
|
||||
<ChangeSection t={props.t} section={section} items={items().filter(item => item.section === section)}
|
||||
git={props.git} canOpenFile={props.canOpenFile} onOpenFile={togglePreview} previewActive={previewActive}>
|
||||
<Show when={section === "staged"}>
|
||||
<div class="git-change-commit-box" role="group" aria-label={props.t("gitPanel.actions")}>
|
||||
<For each={["staged", "unstaged"] as const}>{actionSection => {
|
||||
const targets = createMemo(() => props.git.gitActionItems().filter(item => item.section === actionSection))
|
||||
const action = actionSection === "staged" ? "unstage" : "stage"
|
||||
return <Show when={targets().length > 0}><button class="git-panel-more" title={targets().map(item => item.path).join("\n")} onClick={() => {
|
||||
const item = targets()[0]
|
||||
if (item) actionSection === "staged" ? props.git.unstageGitFile(item) : props.git.stageGitFile(item)
|
||||
}}>{targets().length > 1
|
||||
? props.t(`instanceShell.gitChanges.actions.${action}Selected`, { count: targets().length })
|
||||
: `${props.t(`instanceShell.gitChanges.actions.${action}`)} · ${targets()[0]?.path ?? ""}`}</button></Show>
|
||||
}}</For>
|
||||
<div class="git-change-commit-input-wrap">
|
||||
<textarea class="git-change-commit-input" rows={1} aria-label={props.t("instanceShell.gitChanges.commit.placeholder")} placeholder={props.t("instanceShell.gitChanges.commit.placeholder")} value={props.git.gitCommitMessage()} onInput={event => props.git.setGitCommitMessage(event.currentTarget.value)} />
|
||||
<button type="button" class="git-change-commit-button git-change-commit-button-overlay" disabled={!props.git.gitCommitMessage().trim() || !items().some(item => item.section === "staged") || props.git.gitCommitSubmitting()} onClick={() => void props.git.submitGitCommit()}>{props.t(props.git.gitCommitSubmitting() ? "instanceShell.gitChanges.commit.submitting" : "instanceShell.gitChanges.commit.submit")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</ChangeSection>
|
||||
}</For>
|
||||
</DragDropSensors>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
const ChangeSection: Component<{
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
section: GitChangeSection
|
||||
items: GitChangeListItem[]
|
||||
git: ReturnType<typeof useGitChanges>
|
||||
canOpenFile: boolean
|
||||
onOpenFile: FilesPanelProps["onOpenFile"]
|
||||
previewActive: (file: Parameters<FilesPanelProps["onOpenFile"]>[0]) => boolean
|
||||
children?: JSX.Element
|
||||
}> = sectionProps => {
|
||||
const droppable = createDroppable(sectionProps.section)
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const contentId = createUniqueId()
|
||||
return <div ref={droppable} class="git-drop-zone git-change-section" classList={{ "git-drop-active": droppable.isActiveDroppable }}>
|
||||
<button type="button" class="git-change-section-header" aria-expanded={open()} aria-controls={contentId} onClick={() => setOpen(value => !value)}>
|
||||
<span class="git-change-section-header-main">
|
||||
<span class="git-change-section-chevron disclosure-chevron"><ChevronRight class="w-3.5 h-3.5" aria-hidden="true" /></span>
|
||||
<span class="git-change-section-title">{sectionProps.t(`instanceShell.gitChanges.sections.${sectionProps.section}`)}</span>
|
||||
</span>
|
||||
<span class="git-change-section-count">{sectionProps.items.length}</span>
|
||||
</button>
|
||||
<div id={contentId} hidden={!open()}>
|
||||
{sectionProps.children}
|
||||
<For each={sectionProps.items}>{item => <GitChangeRow t={sectionProps.t} item={item} git={sectionProps.git}
|
||||
canOpenFile={sectionProps.canOpenFile} onOpenFile={sectionProps.onOpenFile} previewActive={sectionProps.previewActive} />}</For>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const GitChangeRow: Component<{
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
item: GitChangeListItem
|
||||
git: ReturnType<typeof useGitChanges>
|
||||
canOpenFile: boolean
|
||||
onOpenFile: FilesPanelProps["onOpenFile"]
|
||||
previewActive: (file: Parameters<FilesPanelProps["onOpenFile"]>[0]) => boolean
|
||||
}> = rowProps => {
|
||||
const draggable = createDraggable(rowProps.item.id)
|
||||
const staged = () => rowProps.item.section === "staged"
|
||||
const toggleStage = () => {
|
||||
if (staged()) rowProps.git.unstageGitFile(rowProps.item)
|
||||
else rowProps.git.stageGitFile(rowProps.item)
|
||||
}
|
||||
const stageLabel = () => rowProps.t(staged() ? "instanceShell.gitChanges.actions.unstage" : "instanceShell.gitChanges.actions.stage")
|
||||
const target = () => ({ path: rowProps.item.path, originalPath: rowProps.item.originalPath, scope: rowProps.item.section })
|
||||
// Keep action identities stable when another reader opens. Recreating every
|
||||
// icon/menu for a preview-state change stalls large inventories, even hidden.
|
||||
const actions: ActionOverflowMenuItem[] = [
|
||||
{ key: "preview", get label() { return `${rowProps.t("filesPanel.viewer")} · ${rowProps.item.path}` }, icon: <Eye size={14} />,
|
||||
get checked() { return rowProps.previewActive(target()) }, get disabled() { return !rowProps.canOpenFile }, onSelect: () => rowProps.onOpenFile(target()) },
|
||||
{ key: "stage", get label() { return `${stageLabel()} · ${rowProps.item.path}` },
|
||||
icon: <Show when={staged()} fallback={<Plus size={14} />}><Minus size={14} /></Show>, onSelect: toggleStage },
|
||||
]
|
||||
return <div ref={draggable} class="git-panel-file-row" classList={{ "git-panel-file-selected": rowProps.git.gitActionItems().some(selected => selected.id === rowProps.item.id) }} style={transformStyle(draggable.transform)}>
|
||||
<button class="git-panel-file git-panel-file-main" aria-pressed={rowProps.git.gitActionItems().some(selected => selected.id === rowProps.item.id)} aria-current={rowProps.git.gitSelectedItemId() === rowProps.item.id ? "true" : undefined} title={rowProps.item.path} disabled={!rowProps.canOpenFile} onClick={event => {
|
||||
rowProps.git.handleGitRowClick(rowProps.item, event)
|
||||
}}><span class="git-file-status">{rowProps.item.status.slice(0, 1).toUpperCase()}</span><span>{rowProps.item.path}</span><small><b class="file-list-item-additions">+{rowProps.item.additions}</b> <b class="file-list-item-deletions">−{rowProps.item.deletions}</b></small></button>
|
||||
<FileRowActions label={rowProps.t("instanceShell.filesShell.actions.more", { name: rowProps.item.path })} items={actions} />
|
||||
</div>
|
||||
}
|
||||
export default FilesPanel
|
||||
|
|
@ -1,493 +0,0 @@
|
|||
import { For, Show, Suspense, createEffect, createMemo, createSignal, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
|
||||
import { Copy, ExternalLink, FolderOpen, RefreshCw, Save, Search, TerminalSquare, WrapText } from "lucide-solid"
|
||||
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import { Markdown } from "../../../../markdown"
|
||||
import { copyToClipboard } from "../../../../../lib/clipboard"
|
||||
import { showToastNotification } from "../../../../../lib/notifications"
|
||||
import { useTheme } from "../../../../../lib/theme"
|
||||
import ActionOverflowMenu, { type ActionOverflowMenuItem } from "../../../../action-overflow-menu"
|
||||
import { canOpenWorkspacePaths, openWorkspacePath } from "../../../../../lib/workspace-open"
|
||||
|
||||
const LazyMonacoFileViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
|
||||
)
|
||||
|
||||
const isWindowsDesktop = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent)
|
||||
const isMacDesktop = typeof navigator !== "undefined" && /macintosh|mac os x/i.test(navigator.userAgent)
|
||||
const safeWindowsOpenExtensions = new Set([
|
||||
"7z", "avi", "bmp", "c", "cc", "cfg", "conf", "cpp", "cs", "css", "csv", "dart", "diff", "docx",
|
||||
"env", "flac", "fs", "fsx", "gif", "go", "gz", "h", "hpp", "htm", "html", "ini", "java", "jpeg", "jpg",
|
||||
"json", "jsonc", "jsx", "kt", "kts", "less", "lock", "log", "lua", "md", "markdown", "mkv", "mov", "mp3",
|
||||
"mp4", "ogg", "patch", "pdf", "png", "pptx", "r", "rar", "rmd", "rs", "scss", "sql", "svg", "svelte",
|
||||
"swift", "tar", "toml", "ts", "tsx", "txt", "vue", "wav", "webm", "webp", "xlsx", "xml", "yaml", "yml", "zip",
|
||||
])
|
||||
const windowsEditExtensions = new Set([
|
||||
"bat", "cmd", "js", "jse", "pl", "ps1", "psd1", "psm1", "py", "pyw", "rb", "reg", "vbe", "vbs", "wsf", "wsh",
|
||||
])
|
||||
|
||||
function windowsDefaultAction(path: string): "open" | "edit" | "choose" {
|
||||
const name = path.split(/[\\/]/).pop() ?? ""
|
||||
const dot = name.lastIndexOf(".")
|
||||
const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : ""
|
||||
if (!isWindowsDesktop || (extension && safeWindowsOpenExtensions.has(extension))) return "open"
|
||||
if (extension && windowsEditExtensions.has(extension)) return "edit"
|
||||
return "choose"
|
||||
}
|
||||
|
||||
function isMarkdownPath(path: string | null | undefined): boolean {
|
||||
if (!path) return false
|
||||
return /\.(md|markdown|mdown|mkdn)$/i.test(path)
|
||||
}
|
||||
|
||||
export interface FileBrowserEntry {
|
||||
name: string
|
||||
path: string
|
||||
type: "file" | "directory"
|
||||
}
|
||||
|
||||
interface FilesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
browserPath: Accessor<string>
|
||||
browserEntries: Accessor<FileBrowserEntry[] | null>
|
||||
browserLoading: Accessor<boolean>
|
||||
browserError: Accessor<string | null>
|
||||
|
||||
browserSelectedPath: Accessor<string | null>
|
||||
browserSelectedContent: Accessor<string | null>
|
||||
browserSelectedLoading: Accessor<boolean>
|
||||
browserSelectedError: Accessor<string | null>
|
||||
browserSelectedDirty: Accessor<boolean>
|
||||
browserSelectedSaving: Accessor<boolean>
|
||||
wordWrapMode: Accessor<"on" | "off">
|
||||
|
||||
parentPath: Accessor<string | null>
|
||||
scopeKey: Accessor<string>
|
||||
instanceId: string
|
||||
worktreeSlug: Accessor<string>
|
||||
|
||||
onLoadEntries: (path: string) => void
|
||||
onRequestOpenFile: (path: string) => void
|
||||
onRefresh: () => void
|
||||
onSave: (content: string) => void
|
||||
onContentChange: (content: string) => void
|
||||
onWordWrapModeChange: (mode: "on" | "off") => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
splitWidth: Accessor<number>
|
||||
onResizeMouseDown: (event: MouseEvent) => void
|
||||
onResizeTouchStart: (event: TouchEvent) => void
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
}
|
||||
|
||||
const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
const [filterQuery, setFilterQuery] = createSignal("")
|
||||
const { isDark } = useTheme()
|
||||
const [markdownPreviewEnabled, setMarkdownPreviewEnabled] = createSignal(false)
|
||||
let markdownPreviewRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
props.browserPath()
|
||||
setFilterQuery("")
|
||||
})
|
||||
|
||||
const sortedEntries = createMemo(() => {
|
||||
const entries = props.browserEntries() || []
|
||||
return [...entries].sort((a, b) => {
|
||||
const aDir = a.type === "directory" ? 0 : 1
|
||||
const bDir = b.type === "directory" ? 0 : 1
|
||||
if (aDir !== bDir) return aDir - bDir
|
||||
return String(a.name || "").localeCompare(String(b.name || ""))
|
||||
})
|
||||
})
|
||||
|
||||
const normalizedQuery = createMemo(() => filterQuery().trim().toLowerCase())
|
||||
|
||||
const filteredEntries = createMemo(() => {
|
||||
const query = normalizedQuery()
|
||||
const entries = sortedEntries()
|
||||
if (!query) return entries
|
||||
return entries.filter((item) => {
|
||||
const name = String(item.name || "").toLowerCase()
|
||||
return name.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const initialListLoading = () => props.browserLoading() && props.browserEntries() === null
|
||||
|
||||
const listEmptyMessage = () =>
|
||||
normalizedQuery() ? props.t("instanceShell.filesShell.search.empty") : props.t("instanceShell.filesShell.listEmpty")
|
||||
|
||||
const selectedMarkdownFile = createMemo(() => isMarkdownPath(props.browserSelectedPath()))
|
||||
const showingMarkdownPreview = createMemo(() => selectedMarkdownFile() && markdownPreviewEnabled())
|
||||
|
||||
createEffect(() => {
|
||||
if (!selectedMarkdownFile()) {
|
||||
setMarkdownPreviewEnabled(false)
|
||||
}
|
||||
})
|
||||
const handleSave = () => {
|
||||
const content = props.browserSelectedContent()
|
||||
if (content !== undefined && content !== null) {
|
||||
props.onSave(content)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyPath = async (path: string, event?: MouseEvent) => {
|
||||
event?.stopPropagation()
|
||||
const ok = await copyToClipboard(path)
|
||||
showToastNotification({
|
||||
message: ok ? props.t("instanceShell.filesShell.toast.copyPathSuccess") : props.t("instanceShell.filesShell.toast.copyPathError"),
|
||||
variant: ok ? "success" : "error",
|
||||
})
|
||||
}
|
||||
|
||||
const handleNativeOpen = async (
|
||||
target: "default" | "reveal" | "terminal",
|
||||
path: string,
|
||||
) => {
|
||||
try {
|
||||
await openWorkspacePath({
|
||||
target,
|
||||
instanceId: props.instanceId,
|
||||
worktreeSlug: props.worktreeSlug(),
|
||||
path,
|
||||
})
|
||||
} catch (error) {
|
||||
showToastNotification({
|
||||
message: props.t("instanceShell.filesShell.toast.openError", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rowActions = (item: FileBrowserEntry): ActionOverflowMenuItem[] => {
|
||||
const items: ActionOverflowMenuItem[] = []
|
||||
if (canOpenWorkspacePaths()) {
|
||||
if (item.type === "directory") {
|
||||
const isMacApp = isMacDesktop && item.path.toLowerCase().endsWith(".app")
|
||||
items.push(
|
||||
{
|
||||
key: "open-folder",
|
||||
label: props.t(isMacApp
|
||||
? "instanceShell.filesShell.actions.showInFolder"
|
||||
: "instanceShell.filesShell.actions.openFolder"),
|
||||
icon: <FolderOpen class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleNativeOpen(isMacApp ? "reveal" : "default", item.path),
|
||||
},
|
||||
{
|
||||
key: "open-terminal",
|
||||
label: props.t("instanceShell.filesShell.actions.openTerminal"),
|
||||
icon: <TerminalSquare class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleNativeOpen("terminal", item.path),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
if (isWindowsDesktop) {
|
||||
items.push({
|
||||
key: "open-default",
|
||||
label: props.t({
|
||||
open: "instanceShell.filesShell.actions.openDefault",
|
||||
edit: "instanceShell.filesShell.actions.editDefault",
|
||||
choose: "instanceShell.filesShell.actions.chooseApplication",
|
||||
}[windowsDefaultAction(item.path)]),
|
||||
icon: <ExternalLink class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleNativeOpen("default", item.path),
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
key: "show-in-folder",
|
||||
label: props.t("instanceShell.filesShell.actions.showInFolder"),
|
||||
icon: <FolderOpen class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleNativeOpen("reveal", item.path),
|
||||
})
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
key: "copy-path",
|
||||
label: props.t("instanceShell.filesShell.actions.copyPath"),
|
||||
icon: <Copy class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleCopyPath(item.path),
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!showingMarkdownPreview()) return
|
||||
requestAnimationFrame(() => markdownPreviewRef?.focus())
|
||||
})
|
||||
|
||||
const FileList: Component = () => (
|
||||
<>
|
||||
<div class="px-2 py-2 border-b border-base">
|
||||
<div class="selector-input-group">
|
||||
<div class="flex items-center gap-2 px-3 text-muted">
|
||||
<Search class="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={filterQuery()}
|
||||
onInput={(event) => setFilterQuery(event.currentTarget.value)}
|
||||
placeholder={props.t("instanceShell.filesShell.search.placeholder")}
|
||||
aria-label={props.t("instanceShell.filesShell.search.ariaLabel")}
|
||||
class="selector-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-list-header">
|
||||
<span class="file-list-title">{props.t("instanceShell.filesShell.fileListTitle")}</span>
|
||||
<span class="file-list-count">{filteredEntries().length}</span>
|
||||
</div>
|
||||
|
||||
<Show when={props.parentPath()}>
|
||||
{(p) => (
|
||||
<div class="file-list-item" onClick={() => props.onLoadEntries(p())}>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={p()}>
|
||||
<span class="file-path-text">..</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={initialListLoading()}>
|
||||
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!props.browserError() && !initialListLoading() && filteredEntries().length > 0}
|
||||
fallback={
|
||||
!initialListLoading()
|
||||
? props.browserError()
|
||||
? <div class="p-3 text-xs text-error">{props.browserError()}</div>
|
||||
: <div class="p-3 text-xs text-secondary">{listEmptyMessage()}</div>
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<For each={filteredEntries()}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
onClick={() => {
|
||||
if (item.type === "directory") {
|
||||
props.onLoadEntries(item.path)
|
||||
return
|
||||
}
|
||||
props.onRequestOpenFile(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.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="file-list-item-stats">
|
||||
<span class="text-[10px] text-secondary">{item.type}</span>
|
||||
</div>
|
||||
<Show
|
||||
when={canOpenWorkspacePaths()}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
class="git-change-row-action"
|
||||
title={props.t("instanceShell.filesShell.actions.copyPath")}
|
||||
aria-label={props.t("instanceShell.filesShell.actions.copyPath")}
|
||||
onClick={(event) => void handleCopyPath(item.path, event)}
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div onClick={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<ActionOverflowMenu
|
||||
items={rowActions(item)}
|
||||
label={props.t("instanceShell.filesShell.actions.more", { name: item.name })}
|
||||
triggerClass="git-change-row-action file-row-action-menu"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
const handleMarkdownPreviewKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "s") return
|
||||
if (props.browserSelectedSaving() || !props.browserSelectedDirty()) return
|
||||
event.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||
|
||||
const emptyViewerMessage = () => {
|
||||
if (initialListLoading()) return props.t("instanceInfo.loading")
|
||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||
}
|
||||
|
||||
const renderViewer = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class={showingMarkdownPreview() ? "file-viewer-content" : "file-viewer-content file-viewer-content--monaco"}>
|
||||
<Show
|
||||
when={props.browserSelectedLoading()}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.browserSelectedError()}
|
||||
fallback={
|
||||
<Show
|
||||
when={
|
||||
props.browserSelectedPath() && props.browserSelectedContent() !== null
|
||||
? { path: props.browserSelectedPath() as string, content: props.browserSelectedContent() as string }
|
||||
: null
|
||||
}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(payload) => (
|
||||
<Show
|
||||
when={showingMarkdownPreview()}
|
||||
fallback={
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoFileViewer
|
||||
scopeKey={props.scopeKey()}
|
||||
path={payload().path}
|
||||
content={payload().content}
|
||||
wordWrap={props.wordWrapMode()}
|
||||
onSave={props.onSave}
|
||||
onContentChange={props.onContentChange}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={markdownPreviewRef}
|
||||
class="h-full outline-none"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleMarkdownPreviewKeyDown}
|
||||
onMouseDown={() => markdownPreviewRef?.focus()}
|
||||
>
|
||||
<Markdown part={{ type: "text", text: payload().content }} isDark={isDark()} escapeRawHtml />
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(err) => (
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{err()}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<div class="files-tab-stats">
|
||||
<span class="files-tab-stat">
|
||||
<span class="files-tab-selected-path" title={headerDisplayedPath()}>
|
||||
<span class="file-path-text">{headerDisplayedPath()}</span>
|
||||
</span>
|
||||
</span>
|
||||
<Show when={props.browserLoading()}>
|
||||
<span>{props.t("instanceInfo.loading")}</span>
|
||||
</Show>
|
||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${showingMarkdownPreview() ? " active" : ""}`}
|
||||
disabled={!selectedMarkdownFile()}
|
||||
style={{ "margin-inline-start": "auto" }}
|
||||
onClick={() => selectedMarkdownFile() && setMarkdownPreviewEnabled((prev) => !prev)}
|
||||
>
|
||||
{showingMarkdownPreview()
|
||||
? props.t("instanceShell.filesShell.showSource")
|
||||
: props.t("instanceShell.filesShell.previewMarkdown")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.save") || "Save (Ctrl+S)"}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.save") || "Save"}
|
||||
disabled={props.browserSelectedSaving() || !props.browserSelectedDirty()}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<Show when={props.browserSelectedSaving()} fallback={<Save class="h-4 w-4" />}>
|
||||
<RefreshCw class="h-4 w-4 animate-spin" />
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
disabled={props.browserLoading()}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
list={{ panel: () => <FileList />, overlay: () => <FileList /> }}
|
||||
viewer={renderViewer()}
|
||||
viewerActions={
|
||||
<button
|
||||
type="button"
|
||||
class="file-viewer-toolbar-icon-button icon-toggle"
|
||||
title={props.wordWrapMode() === "on" ? props.t("instanceShell.filesShell.disableWordWrap") : props.t("instanceShell.filesShell.enableWordWrap")}
|
||||
aria-label={props.wordWrapMode() === "on" ? props.t("instanceShell.filesShell.disableWordWrap") : props.t("instanceShell.filesShell.enableWordWrap")}
|
||||
data-active={props.wordWrapMode() === "on" ? "true" : undefined}
|
||||
disabled={showingMarkdownPreview()}
|
||||
onClick={() => props.onWordWrapModeChange(props.wordWrapMode() === "on" ? "off" : "on")}
|
||||
>
|
||||
<WrapText class="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
listOpen={props.listOpen()}
|
||||
onToggleList={props.onToggleList}
|
||||
splitWidth={props.splitWidth()}
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{renderContent()}</>
|
||||
}
|
||||
|
||||
export default FilesTab
|
||||
|
|
@ -1,419 +0,0 @@
|
|||
import {
|
||||
For,
|
||||
Show,
|
||||
Suspense,
|
||||
createMemo,
|
||||
lazy,
|
||||
type Accessor,
|
||||
type Component,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
|
||||
import { ChevronRight, GitBranch, RefreshCw, WrapText } from "lucide-solid"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
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 })),
|
||||
)
|
||||
|
||||
interface GitChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
activeSessionId: Accessor<string | null>
|
||||
|
||||
entries: Accessor<GitChangeEntry[] | null>
|
||||
statusLoading: Accessor<boolean>
|
||||
statusError: 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>
|
||||
mostChangedItemId: Accessor<string | null>
|
||||
|
||||
scopeKey: Accessor<string>
|
||||
|
||||
diffViewMode: Accessor<DiffViewMode>
|
||||
diffContextMode: Accessor<DiffContextMode>
|
||||
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => 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
|
||||
splitWidth: Accessor<number>
|
||||
onResizeMouseDown: (event: MouseEvent) => void
|
||||
onResizeTouchStart: (event: TouchEvent) => void
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
}
|
||||
|
||||
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
const sessionId = createMemo(() => props.activeSessionId())
|
||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
||||
|
||||
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 listItems().reduce(
|
||||
(acc, item) => {
|
||||
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 selectedEntry = createMemo<GitChangeEntry | null>(() => {
|
||||
const list = listItems()
|
||||
const selectedId = props.selectedItemId()
|
||||
const fallbackId = props.mostChangedItemId()
|
||||
const found =
|
||||
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 (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 wordWrapTitle = () => props.diffWordWrapMode() === "on"
|
||||
? props.t("instanceShell.diff.disableWordWrap")
|
||||
: props.t("instanceShell.diff.enableWordWrap")
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
const totalsValue = totals()
|
||||
const selected = selectedEntry()
|
||||
const allItems = listItems()
|
||||
const stagedList = stagedItems()
|
||||
const unstagedList = unstagedItems()
|
||||
|
||||
const renderViewer = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={props.selectedLoading()}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.selectedError()}
|
||||
fallback={
|
||||
<Show
|
||||
when={
|
||||
selected &&
|
||||
props.selectedBefore() !== null &&
|
||||
props.selectedAfter() !== null &&
|
||||
true
|
||||
? {
|
||||
path: selected.path,
|
||||
before: props.selectedBefore() as string,
|
||||
after: props.selectedAfter() as string,
|
||||
}
|
||||
: null
|
||||
}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoDiffViewer
|
||||
scopeKey={props.scopeKey()}
|
||||
path={String(file().path || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
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>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(err) => (
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{err()}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</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>
|
||||
</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" aria-expanded={isOpen} onClick={onToggle}>
|
||||
<span class="git-change-section-header-main">
|
||||
<span class="git-change-section-chevron disclosure-chevron">
|
||||
<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 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" aria-expanded={props.stagedOpen()} onClick={props.onToggleStagedOpen}>
|
||||
<span class="git-change-section-header-main">
|
||||
<span class="git-change-section-chevron disclosure-chevron">
|
||||
<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>
|
||||
</Show>
|
||||
</div>
|
||||
{renderSection(
|
||||
props.t("instanceShell.gitChanges.sections.unstaged"),
|
||||
unstagedList,
|
||||
props.unstagedOpen(),
|
||||
props.onToggleUnstagedOpen,
|
||||
)}
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
return (
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
|
||||
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
||||
</span>
|
||||
|
||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||
<span class="files-tab-stat files-tab-stat-additions">
|
||||
<span class="files-tab-stat-value">+{totalsValue.additions}</span>
|
||||
</span>
|
||||
<span class="files-tab-stat files-tab-stat-deletions">
|
||||
<span class="files-tab-stat-value">-{totalsValue.deletions}</span>
|
||||
</span>
|
||||
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
disabled={!hasSession() || props.statusLoading() || entries() === null}
|
||||
style={{ "margin-left": "auto" }}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
||||
</button>
|
||||
|
||||
<DiffToolbar
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
onViewModeChange={props.onViewModeChange}
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
/>
|
||||
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderGroupedList, overlay: renderGroupedList }}
|
||||
viewer={renderViewer()}
|
||||
viewerActions={
|
||||
<button
|
||||
type="button"
|
||||
class="file-viewer-toolbar-icon-button icon-toggle"
|
||||
onClick={() => props.onWordWrapModeChange(props.diffWordWrapMode() === "on" ? "off" : "on")}
|
||||
aria-label={wordWrapTitle()}
|
||||
aria-pressed={props.diffWordWrapMode() === "on"}
|
||||
title={wordWrapTitle()}
|
||||
>
|
||||
<WrapText class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
listOpen={props.listOpen()}
|
||||
onToggleList={props.onToggleList}
|
||||
splitWidth={props.splitWidth()}
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{renderContent()}</>
|
||||
}
|
||||
|
||||
export default GitChangesTab
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import type { FileSystemEntry } from "@opencode/client"
|
||||
import type { FileBrowserEntry } from "./FilesTab"
|
||||
|
||||
export function adaptFileSystemEntries(entries: FileSystemEntry[]): FileBrowserEntry[] {
|
||||
return entries.map((entry) => {
|
||||
const path = entry.path.replace(/\\+/g, "/").replace(/\/+$/, "")
|
||||
return { ...entry, path, name: path.split("/").pop() || path }
|
||||
})
|
||||
}
|
||||
|
||||
export function decodeFileContent(content: Uint8Array): string {
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(content)
|
||||
if (text.includes("\0")) throw new Error("Binary file cannot be displayed")
|
||||
return text
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { adaptFileSystemEntries, decodeFileContent } from "./file-v2-adapters.ts"
|
||||
|
||||
describe("files runtime V2 adapters", () => {
|
||||
it("adds names to native file system entries", () => {
|
||||
assert.deepEqual(adaptFileSystemEntries([
|
||||
{ path: "src/components", type: "directory" },
|
||||
{ path: "src\\index.ts", type: "file" },
|
||||
]), [
|
||||
{ path: "src/components", name: "components", type: "directory" },
|
||||
{ path: "src/index.ts", name: "index.ts", type: "file" },
|
||||
])
|
||||
})
|
||||
|
||||
it("decodes native byte output and rejects binary content", () => {
|
||||
assert.equal(decodeFileContent(new TextEncoder().encode("hello\n")), "hello\n")
|
||||
assert.throws(() => decodeFileContent(Uint8Array.of(0xff)))
|
||||
assert.throws(() => decodeFileContent(Uint8Array.of(0)))
|
||||
})
|
||||
})
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
import { createEffect, createMemo, createSignal, lazy, onCleanup, type Accessor, type JSX } from "solid-js"
|
||||
|
||||
import type { DiffWordWrapMode, RightPanelTab } from "../types"
|
||||
import type { FileBrowserEntry } from "./FilesTab"
|
||||
import { adaptFileSystemEntries, decodeFileContent } from "./file-v2-adapters"
|
||||
|
||||
import { getRootClient } from "../../../../../stores/opencode-client"
|
||||
import { instances } from "../../../../../stores/instances"
|
||||
import { getWorktrees } from "../../../../../stores/worktrees"
|
||||
import { serverApi } from "../../../../../lib/api-client"
|
||||
import { showConfirmDialog } from "../../../../../stores/alerts"
|
||||
import { showToastNotification } from "../../../../../lib/notifications"
|
||||
import { createDebouncedRefresh, filesystemInvalidationVersion } from "../../../../../lib/filesystem-events"
|
||||
import { writeClientLayoutValue } from "../../../../../stores/client-state"
|
||||
import {
|
||||
RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
||||
RIGHT_PANEL_FILES_WORD_WRAP_KEY,
|
||||
readStoredBool,
|
||||
readStoredEnum,
|
||||
} from "../../storage"
|
||||
import { createSplitResize } from "./split-resize"
|
||||
|
||||
const LazyFilesTab = lazy(() => import("./FilesTab"))
|
||||
|
||||
interface FilesTabRuntimeOptions {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
instanceId: string
|
||||
rightPanelTab: Accessor<RightPanelTab>
|
||||
worktreeSlug: Accessor<string>
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
rightDrawerWidth: Accessor<number>
|
||||
rightDrawerWidthInitialized: Accessor<boolean>
|
||||
}
|
||||
|
||||
export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JSX.Element {
|
||||
const [browserPath, setBrowserPath] = createSignal(".")
|
||||
const [browserEntries, setBrowserEntries] = createSignal<FileBrowserEntry[] | null>(null)
|
||||
const [browserLoading, setBrowserLoading] = createSignal(false)
|
||||
const [browserError, setBrowserError] = createSignal<string | null>(null)
|
||||
const [browserSelectedPath, setBrowserSelectedPath] = createSignal<string | null>(null)
|
||||
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
||||
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
||||
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
||||
const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false)
|
||||
const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false)
|
||||
const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal<string | null>(null)
|
||||
const [filesWordWrapMode, setFilesWordWrapMode] = createSignal<DiffWordWrapMode>(
|
||||
readStoredEnum(RIGHT_PANEL_FILES_WORD_WRAP_KEY, ["on", "off"] as const) ?? "off",
|
||||
)
|
||||
const [filesListOpen, setFilesListOpen] = createSignal(true)
|
||||
const [filesListTouched, setFilesListTouched] = createSignal(false)
|
||||
const browserClient = createMemo(() => getRootClient(options.instanceId))
|
||||
const filesSplit = createSplitResize({
|
||||
storageKey: RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
||||
defaultWidth: 320,
|
||||
rightDrawerWidth: options.rightDrawerWidth,
|
||||
rightDrawerWidthInitialized: options.rightDrawerWidthInitialized,
|
||||
})
|
||||
|
||||
const filesListOpenStorageKey = createMemo(() =>
|
||||
options.isPhoneLayout() ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
|
||||
)
|
||||
|
||||
const fileLocation = () => {
|
||||
const slug = options.worktreeSlug()
|
||||
const directory = getWorktrees(options.instanceId).find((worktree) => worktree.slug === slug)?.directory
|
||||
?? (slug === "root" ? instances().get(options.instanceId)?.folder : undefined)
|
||||
if (!directory) throw new Error(`Missing directory for worktree ${slug}`)
|
||||
return { directory }
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
filesListOpenStorageKey()
|
||||
const persisted = readStoredBool(filesListOpenStorageKey())
|
||||
if (persisted !== null) {
|
||||
setFilesListOpen(persisted)
|
||||
setFilesListTouched(true)
|
||||
} else {
|
||||
setFilesListOpen(true)
|
||||
setFilesListTouched(false)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (options.rightPanelTab() !== "files") return
|
||||
if (filesListTouched()) return
|
||||
if (!browserSelectedPath()) setFilesListOpen(true)
|
||||
})
|
||||
|
||||
createEffect(() => writeClientLayoutValue(RIGHT_PANEL_FILES_WORD_WRAP_KEY, filesWordWrapMode()))
|
||||
|
||||
createEffect(() => {
|
||||
options.worktreeSlug()
|
||||
setBrowserPath(".")
|
||||
setBrowserEntries(null)
|
||||
setBrowserError(null)
|
||||
setBrowserSelectedPath(null)
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedLoading(false)
|
||||
})
|
||||
|
||||
const normalizeBrowserPath = (input: string) => {
|
||||
const raw = String(input || ".").trim()
|
||||
if (!raw || raw === "./") return "."
|
||||
const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "")
|
||||
return cleaned === "" ? "." : cleaned
|
||||
}
|
||||
|
||||
const getParentPath = (path: string): string | null => {
|
||||
const current = normalizeBrowserPath(path)
|
||||
if (current === ".") return null
|
||||
const parts = current.split("/").filter(Boolean)
|
||||
parts.pop()
|
||||
return parts.length ? parts.join("/") : "."
|
||||
}
|
||||
|
||||
const loadBrowserEntries = async (path: string) => {
|
||||
const normalized = normalizeBrowserPath(path)
|
||||
setBrowserLoading(true)
|
||||
setBrowserError(null)
|
||||
try {
|
||||
const result = await browserClient().file.list({ path: normalized, location: fileLocation() })
|
||||
setBrowserPath(normalized)
|
||||
setBrowserEntries(adaptFileSystemEntries(result.data))
|
||||
} catch (error) {
|
||||
setBrowserError(error instanceof Error ? error.message : "Failed to load files")
|
||||
setBrowserEntries([])
|
||||
} finally {
|
||||
setBrowserLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openBrowserFile = async (path: string) => {
|
||||
setBrowserSelectedPath(path)
|
||||
setBrowserSelectedLoading(true)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedDirty(false)
|
||||
setBrowserSelectedOriginalContent(null)
|
||||
|
||||
if (options.isPhoneLayout()) setFilesListOpen(false)
|
||||
try {
|
||||
const text = decodeFileContent(await browserClient().file.read({ path, location: fileLocation() }))
|
||||
setBrowserSelectedContent(text)
|
||||
setBrowserSelectedOriginalContent(text)
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||
} finally {
|
||||
setBrowserSelectedLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveBrowserFile = async (content: string): Promise<boolean> => {
|
||||
const path = browserSelectedPath()
|
||||
if (!path) return false
|
||||
|
||||
const originalContent = browserSelectedOriginalContent()
|
||||
if (originalContent !== null) {
|
||||
try {
|
||||
const diskContent = decodeFileContent(await browserClient().file.read({ path, location: fileLocation() }))
|
||||
if (diskContent !== originalContent && diskContent !== content) {
|
||||
const confirmed = await showConfirmDialog(options.t("instanceShell.rightPanel.actions.conflict.message", { path }), {
|
||||
variant: "warning",
|
||||
confirmLabel: options.t("instanceShell.rightPanel.actions.conflict.confirmLabel"),
|
||||
cancelLabel: options.t("instanceShell.rightPanel.actions.conflict.cancelLabel"),
|
||||
dismissible: false,
|
||||
})
|
||||
if (!confirmed) return false
|
||||
}
|
||||
} catch {
|
||||
// If conflict detection fails, keep the existing behavior and try the save.
|
||||
}
|
||||
}
|
||||
|
||||
setBrowserSelectedSaving(true)
|
||||
try {
|
||||
await serverApi.writeWorkspaceFile(options.instanceId, path, content, { worktree: options.worktreeSlug() })
|
||||
setBrowserSelectedContent(content)
|
||||
setBrowserSelectedOriginalContent(content)
|
||||
setBrowserSelectedDirty(false)
|
||||
showToastNotification({ message: options.t("instanceShell.rightPanel.toast.saveSuccess"), variant: "success" })
|
||||
return true
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file")
|
||||
showToastNotification({ message: options.t("instanceShell.rightPanel.toast.saveError"), variant: "error" })
|
||||
return false
|
||||
} finally {
|
||||
setBrowserSelectedSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenBrowserFileRequest = async (path: string) => {
|
||||
if (browserSelectedDirty()) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
options.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }),
|
||||
{
|
||||
variant: "warning",
|
||||
confirmLabel: options.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"),
|
||||
cancelLabel: options.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (confirmed) {
|
||||
const saveSuccess = await saveBrowserFile(browserSelectedContent() || "")
|
||||
if (!saveSuccess) return
|
||||
} else {
|
||||
setBrowserSelectedDirty(false)
|
||||
}
|
||||
}
|
||||
await openBrowserFile(path)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (options.rightPanelTab() !== "files") return
|
||||
if (browserLoading()) return
|
||||
if (browserEntries() !== null) return
|
||||
void loadBrowserEntries(browserPath())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (options.rightPanelTab() === "files") return
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedLoading(false)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedDirty(false)
|
||||
})
|
||||
|
||||
const toggleFilesList = () => {
|
||||
setFilesListTouched(true)
|
||||
setFilesListOpen((current) => {
|
||||
const next = !current
|
||||
writeClientLayoutValue(filesListOpenStorageKey(), next ? "true" : "false")
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const refreshFilesTab = async () => {
|
||||
if (browserSelectedDirty()) {
|
||||
const confirmed = await showConfirmDialog(options.t("instanceShell.rightPanel.actions.refreshDirty.message"), {
|
||||
variant: "warning",
|
||||
confirmLabel: options.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"),
|
||||
cancelLabel: options.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"),
|
||||
dismissible: false,
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
void loadBrowserEntries(browserPath())
|
||||
const selected = browserSelectedPath()
|
||||
if (!selected) return
|
||||
|
||||
setBrowserSelectedLoading(true)
|
||||
setBrowserSelectedError(null)
|
||||
try {
|
||||
const text = decodeFileContent(await browserClient().file.read({ path: selected, location: fileLocation() }))
|
||||
setBrowserSelectedContent(text)
|
||||
setBrowserSelectedOriginalContent(text)
|
||||
setBrowserSelectedDirty(false)
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||
} finally {
|
||||
setBrowserSelectedLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const browserParentPath = createMemo(() => getParentPath(browserPath()))
|
||||
const browserScopeKey = createMemo(() => `${options.instanceId}:${options.worktreeSlug()}`)
|
||||
let seenFilesystemInvalidation = filesystemInvalidationVersion(options.instanceId)
|
||||
const filesystemRefresh = createDebouncedRefresh(() => {
|
||||
void loadBrowserEntries(browserPath())
|
||||
const selected = browserSelectedPath()
|
||||
if (selected && !browserSelectedDirty()) void openBrowserFile(selected)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const version = filesystemInvalidationVersion(options.instanceId)
|
||||
if (version === seenFilesystemInvalidation) return
|
||||
seenFilesystemInvalidation = version
|
||||
if (options.rightPanelTab() === "files") filesystemRefresh.trigger()
|
||||
else setBrowserEntries(null)
|
||||
})
|
||||
onCleanup(() => filesystemRefresh.cancel())
|
||||
|
||||
return () => (
|
||||
<LazyFilesTab
|
||||
t={options.t}
|
||||
browserPath={browserPath}
|
||||
browserEntries={browserEntries}
|
||||
browserLoading={browserLoading}
|
||||
browserError={browserError}
|
||||
browserSelectedPath={browserSelectedPath}
|
||||
browserSelectedContent={browserSelectedContent}
|
||||
browserSelectedLoading={browserSelectedLoading}
|
||||
browserSelectedError={browserSelectedError}
|
||||
browserSelectedDirty={browserSelectedDirty}
|
||||
browserSelectedSaving={browserSelectedSaving}
|
||||
wordWrapMode={filesWordWrapMode}
|
||||
parentPath={browserParentPath}
|
||||
scopeKey={browserScopeKey}
|
||||
instanceId={options.instanceId}
|
||||
worktreeSlug={options.worktreeSlug}
|
||||
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
|
||||
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
|
||||
onRefresh={() => void refreshFilesTab()}
|
||||
onSave={(content: string) => void saveBrowserFile(content)}
|
||||
onContentChange={(content: string) => {
|
||||
setBrowserSelectedContent(content)
|
||||
setBrowserSelectedDirty(true)
|
||||
}}
|
||||
onWordWrapModeChange={setFilesWordWrapMode}
|
||||
listOpen={filesListOpen}
|
||||
onToggleList={toggleFilesList}
|
||||
splitWidth={filesSplit.splitWidth}
|
||||
onResizeMouseDown={filesSplit.onResizeMouseDown}
|
||||
onResizeTouchStart={filesSplit.onResizeTouchStart}
|
||||
isPhoneLayout={options.isPhoneLayout}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
|
||||
import { writeClientLayoutValue } from "../../../../../stores/client-state"
|
||||
import { readStoredPanelWidth } from "../../storage"
|
||||
import { useGlobalPointerDrag } from "../../useGlobalPointerDrag"
|
||||
|
||||
interface SplitResizeOptions {
|
||||
storageKey: string
|
||||
defaultWidth: number
|
||||
rightDrawerWidth: Accessor<number>
|
||||
rightDrawerWidthInitialized: Accessor<boolean>
|
||||
}
|
||||
|
||||
export function createSplitResize(options: SplitResizeOptions) {
|
||||
const [splitWidth, setSplitWidth] = createSignal(options.defaultWidth)
|
||||
const [initialized, setInitialized] = createSignal(false)
|
||||
const [active, setActive] = createSignal(false)
|
||||
const [startX, setStartX] = createSignal(0)
|
||||
const [startWidth, setStartWidth] = createSignal(0)
|
||||
|
||||
const clampSplitWidth = (value: number) => {
|
||||
const min = 200
|
||||
const maxByDrawer = Math.max(min, Math.floor(options.rightDrawerWidth() * 0.65))
|
||||
const max = Math.min(560, maxByDrawer)
|
||||
return Math.min(max, Math.max(min, Math.floor(value)))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (initialized()) return
|
||||
if (!options.rightDrawerWidthInitialized()) return
|
||||
setInitialized(true)
|
||||
setSplitWidth(clampSplitWidth(readStoredPanelWidth(options.storageKey, options.defaultWidth)))
|
||||
})
|
||||
|
||||
const persistSplitWidth = () => {
|
||||
writeClientLayoutValue(options.storageKey, String(splitWidth()))
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
setActive(false)
|
||||
if (typeof document === "undefined") return
|
||||
pointerDrag.stop()
|
||||
}
|
||||
|
||||
function move(clientX: number) {
|
||||
if (!active()) return
|
||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||
const delta = (clientX - startX()) * (isRtl ? -1 : 1)
|
||||
setSplitWidth(clampSplitWidth(startWidth() + delta))
|
||||
}
|
||||
|
||||
const pointerDrag = useGlobalPointerDrag({
|
||||
onMouseMove: (event) => {
|
||||
if (!active()) return
|
||||
event.preventDefault()
|
||||
move(event.clientX)
|
||||
},
|
||||
onMouseUp: () => {
|
||||
if (active()) persistSplitWidth()
|
||||
stopResize()
|
||||
},
|
||||
onTouchMove: (event) => {
|
||||
if (!active()) return
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
event.preventDefault()
|
||||
move(touch.clientX)
|
||||
},
|
||||
onTouchEnd: () => {
|
||||
if (active()) persistSplitWidth()
|
||||
stopResize()
|
||||
},
|
||||
})
|
||||
|
||||
const startResize = (clientX: number) => {
|
||||
if (typeof document === "undefined") return
|
||||
setActive(true)
|
||||
setStartX(clientX)
|
||||
setStartWidth(splitWidth())
|
||||
pointerDrag.start()
|
||||
}
|
||||
|
||||
onCleanup(stopResize)
|
||||
|
||||
return {
|
||||
splitWidth,
|
||||
onResizeMouseDown: (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
startResize(event.clientX)
|
||||
},
|
||||
onResizeTouchStart: (event: TouchEvent) => {
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
event.preventDefault()
|
||||
startResize(touch.clientX)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { registerHooks } from "node:module"
|
||||
import { after, test } from "node:test"
|
||||
import type { WorktreeGitStatusEntry } from "../../../../../../server/src/api-types"
|
||||
import type { FilePreviewTarget } from "../../../../stores/files-preview"
|
||||
|
||||
// Native loader hooks isolate transport, while the actual reactive hook, model,
|
||||
// filesystem invalidations and preview store execute with Solid's client runtime.
|
||||
const fixture = {
|
||||
entries: [] as WorktreeGitStatusEntry[],
|
||||
requests: [] as Array<{ action: string; paths: string[] }>,
|
||||
mutationGate: null as Promise<void> | null,
|
||||
statusGate: null as Promise<void> | null,
|
||||
}
|
||||
;(globalThis as any).__filesGitFixture = fixture
|
||||
const hooks = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === "solid-js") return nextResolve("solid-js/dist/solid.js", context)
|
||||
const sources: Record<string, string> = {
|
||||
"../../../../stores/opencode-client": "export const getRootClient = () => ({ vcs: { status: async () => ({ data: [] }) } })",
|
||||
"../../../../stores/instances": "export const instances = () => new Map()",
|
||||
"../../../../stores/worktrees": "export const getWorktrees = () => [{ slug: 'root', directory: '/repo' }]",
|
||||
"../../../../lib/notifications": "export const showToastNotification = () => {}",
|
||||
"../../../../lib/api-client": `
|
||||
const f = globalThis.__filesGitFixture;
|
||||
async function mutate(action, paths) {
|
||||
f.requests.push({ action, paths });
|
||||
await f.mutationGate;
|
||||
f.entries = f.entries.flatMap(entry => {
|
||||
if (action === 'commit') return entry.unstagedStatus ? [{ ...entry, stagedStatus: null }] : [];
|
||||
if (!paths.includes(entry.path)) return [entry];
|
||||
return [{ ...entry, stagedStatus: action === 'stage' ? 'modified' : null,
|
||||
unstagedStatus: action === 'unstage' ? 'modified' : null }];
|
||||
});
|
||||
}
|
||||
export const serverApi = {
|
||||
async fetchWorktreeGitStatus() { await f.statusGate; return f.entries; },
|
||||
stageWorktreeGitPaths: (_id, _slug, { paths }) => mutate('stage', paths),
|
||||
unstageWorktreeGitPaths: (_id, _slug, { paths }) => mutate('unstage', paths),
|
||||
commitWorktreeGitChanges: () => mutate('commit', []),
|
||||
fetchWorktreeGitDiff: () => { throw new Error('external diffs belong to the reader'); },
|
||||
};
|
||||
`,
|
||||
}
|
||||
if (context.parentURL?.endsWith("/useGitChanges.ts") && sources[specifier]) {
|
||||
return { shortCircuit: true, url: `data:text/javascript,${encodeURIComponent(sources[specifier])}` }
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
},
|
||||
})
|
||||
after(() => { hooks.deregister(); delete (globalThis as any).__filesGitFixture })
|
||||
const { createRoot, createEffect } = await import("solid-js")
|
||||
const { useGitChanges } = await import("./useGitChanges")
|
||||
const { buildGitChangeListItems } = await import("./git-changes-model")
|
||||
const { getFilePreview, openFilePreview, closeFilePreview } = await import("../../../../stores/files-preview")
|
||||
const { invalidateFilesystemCaches } = await import("../../../../lib/filesystem-events")
|
||||
|
||||
const entry = (path: string, staged = false): WorktreeGitStatusEntry => ({
|
||||
path, originalPath: null, stagedStatus: staged ? "modified" : null, unstagedStatus: staged ? null : "modified",
|
||||
stagedAdditions: 1, stagedDeletions: 0, unstagedAdditions: 1, unstagedDeletions: 0,
|
||||
})
|
||||
const deferred = () => {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>(done => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
async function until(predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (predicate()) return
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
}
|
||||
assert.ok(predicate(), "hook settled before timeout")
|
||||
}
|
||||
async function setup(entries = [entry("a.ts"), entry("b.ts"), entry("c.ts")]) {
|
||||
fixture.entries = entries
|
||||
fixture.requests = []
|
||||
fixture.mutationGate = null
|
||||
fixture.statusGate = null
|
||||
let dispose!: () => void
|
||||
const git = createRoot(cleanup => {
|
||||
dispose = cleanup
|
||||
return useGitChanges({
|
||||
instanceId: "test", isActive: () => true, t: key => key, rightPanelTab: () => "git-changes",
|
||||
worktreeSlug: () => "root", isPhoneLayout: () => false, promptInputApi: () => null,
|
||||
closeGitList: () => {}, externalDiff: true,
|
||||
})
|
||||
})
|
||||
await until(() => !git.gitStatusLoading() && git.gitStatusEntries() !== null)
|
||||
const item = (id: string) => {
|
||||
const result = buildGitChangeListItems(git.gitStatusEntries()).find(item => item.id === id)
|
||||
assert.ok(result, `missing ${id}`)
|
||||
return result
|
||||
}
|
||||
const click = (id: string, modifiers: Partial<MouseEvent> = {}) => {
|
||||
const selected = item(id)
|
||||
git.handleGitRowClick(selected, { preventDefault() {}, ...modifiers } as MouseEvent)
|
||||
if (!modifiers.ctrlKey && !modifiers.metaKey && !modifiers.shiftKey) {
|
||||
openFilePreview("test", { sessionId: "session", slug: "root", directory: "/repo",
|
||||
path: selected.path, scope: selected.section, originalPath: selected.originalPath })
|
||||
}
|
||||
}
|
||||
const select = (id: string) => git.handleGitRowClick(item(id), { preventDefault() {} } as MouseEvent)
|
||||
const eye = (id: string) => {
|
||||
const preview = item(id)
|
||||
openFilePreview("test", { sessionId: "session", slug: "root", directory: "/repo",
|
||||
path: preview.path, scope: preview.section, originalPath: preview.originalPath })
|
||||
}
|
||||
return { git, item, click, select, eye, close() { dispose(); closeFilePreview("test") } }
|
||||
}
|
||||
|
||||
test("stage and unstage move the central local preview with its sidebar selection", async () => {
|
||||
const h = await setup([{ ...entry("a.ts"), originalPath: "old.ts" }])
|
||||
try {
|
||||
h.click("unstaged:a.ts")
|
||||
h.git.stageGitFile(h.item("unstaged:a.ts"))
|
||||
await until(() => getFilePreview("test")?.scope === "staged")
|
||||
assert.equal(h.git.gitSelectedItemId(), "staged:a.ts")
|
||||
assert.equal(getFilePreview("test")?.originalPath, "old.ts")
|
||||
assert.equal(getFilePreview("test")?.sessionId, "session")
|
||||
h.git.unstageGitFile(h.item("staged:a.ts"))
|
||||
await until(() => getFilePreview("test")?.scope === "unstaged")
|
||||
assert.equal(h.git.gitSelectedItemId(), "unstaged:a.ts")
|
||||
} finally { h.close() }
|
||||
})
|
||||
|
||||
test("commit closes a vanished preview instead of opening another file; partial commits retain the remaining scope", async () => {
|
||||
for (const partial of [false, true]) {
|
||||
const h = await setup([{ ...entry("a.ts", true), unstagedStatus: partial ? "modified" : null }, entry("b.ts")])
|
||||
try {
|
||||
h.click("staged:a.ts")
|
||||
h.git.setGitCommitMessage("Commit staged changes")
|
||||
await h.git.submitGitCommit()
|
||||
if (partial) {
|
||||
assert.equal(getFilePreview("test")?.scope, "unstaged")
|
||||
assert.equal(getFilePreview("test")?.path, "a.ts")
|
||||
} else assert.equal(getFilePreview("test"), null)
|
||||
assert.equal(h.git.gitCommitMessage(), "")
|
||||
} finally { h.close() }
|
||||
}
|
||||
})
|
||||
|
||||
test("explicit refresh reloads an unchanged target and reconciles externally moved or removed changes", async () => {
|
||||
const h = await setup()
|
||||
let stop!: () => void
|
||||
try {
|
||||
h.click("unstaged:a.ts")
|
||||
let readerLoads = 0
|
||||
createRoot(dispose => { stop = dispose; createEffect(() => { getFilePreview("test"); readerLoads++ }) })
|
||||
await h.git.refreshGitStatus()
|
||||
assert.equal(readerLoads, 2, "target identity drives the real reader's load effect")
|
||||
fixture.entries = [entry("a.ts", true), entry("b.ts")]
|
||||
await h.git.refreshGitStatus()
|
||||
assert.equal(getFilePreview("test")?.scope, "staged")
|
||||
fixture.entries = [entry("b.ts")]
|
||||
await h.git.refreshGitStatus()
|
||||
assert.equal(getFilePreview("test"), null)
|
||||
} finally { stop?.(); h.close() }
|
||||
})
|
||||
|
||||
test("passive filesystem reconciliation also moves the relevant local preview", async () => {
|
||||
const h = await setup()
|
||||
try {
|
||||
h.click("unstaged:a.ts")
|
||||
fixture.entries = [entry("a.ts", true)]
|
||||
invalidateFilesystemCaches("test")
|
||||
await until(() => getFilePreview("test")?.scope === "staged")
|
||||
} finally { h.close() }
|
||||
})
|
||||
|
||||
test("refresh and mutation preserve unrelated previews and fence navigation during pending requests", async () => {
|
||||
const others: Partial<FilePreviewTarget>[] = [
|
||||
{ kind: "workspace" }, { commit: "abcdef" }, { slug: "other" }, { directory: "/other" },
|
||||
]
|
||||
for (const other of others) {
|
||||
const h = await setup()
|
||||
try {
|
||||
h.click("unstaged:a.ts")
|
||||
const unrelated = { ...getFilePreview("test")!, ...other }
|
||||
openFilePreview("test", unrelated)
|
||||
await h.git.refreshGitStatus()
|
||||
assert.equal(getFilePreview("test"), unrelated)
|
||||
h.git.stageGitFile(h.item("unstaged:a.ts"))
|
||||
await until(() => h.git.gitSelectedItemId() === "staged:a.ts")
|
||||
assert.equal(getFilePreview("test"), unrelated)
|
||||
} finally { h.close() }
|
||||
}
|
||||
for (const operation of ["stage", "refresh", "commit"] as const) {
|
||||
const h = await setup([entry("a.ts", operation === "commit"), entry("b.ts")])
|
||||
try {
|
||||
const id = `${operation === "commit" ? "staged" : "unstaged"}:a.ts`
|
||||
h.click(id)
|
||||
const gate = deferred()
|
||||
if (operation === "refresh") fixture.statusGate = gate.promise
|
||||
else fixture.mutationGate = gate.promise
|
||||
h.git.setGitCommitMessage("commit")
|
||||
const pending = operation === "refresh" ? h.git.refreshGitStatus() : operation === "commit"
|
||||
? h.git.submitGitCommit() : h.git.stageGitFile(h.item(id))
|
||||
h.click("unstaged:b.ts")
|
||||
const newer = getFilePreview("test")
|
||||
gate.resolve()
|
||||
await pending
|
||||
await until(() => !h.git.gitStatusLoading() && !h.git.gitCommitSubmitting())
|
||||
assert.equal(getFilePreview("test"), newer)
|
||||
assert.equal(h.git.gitSelectedItemId(), "unstaged:b.ts")
|
||||
} finally { h.close() }
|
||||
}
|
||||
})
|
||||
|
||||
test("per-section Operations targets honor bulk selection excluding the previewed file", async () => {
|
||||
const h = await setup([entry("a.ts"), entry("b.ts"), entry("c.ts"), entry("d.ts", true)])
|
||||
try {
|
||||
h.click("unstaged:a.ts")
|
||||
h.click("unstaged:b.ts", { ctrlKey: true })
|
||||
h.click("unstaged:c.ts", { metaKey: true })
|
||||
h.click("staged:d.ts", { ctrlKey: true })
|
||||
const unstaged = h.git.gitActionItems().filter(item => item.section === "unstaged")
|
||||
const staged = h.git.gitActionItems().filter(item => item.section === "staged")
|
||||
assert.deepEqual(unstaged.map(item => item.path), ["b.ts", "c.ts"])
|
||||
assert.deepEqual(staged.map(item => item.path), ["d.ts"])
|
||||
h.git.stageGitFile(unstaged[0])
|
||||
await until(() => h.git.gitBulkSelectedItemIds().size === 0)
|
||||
assert.deepEqual(fixture.requests, [{ action: "stage", paths: ["b.ts", "c.ts"] }])
|
||||
assert.equal(getFilePreview("test")?.path, "a.ts")
|
||||
assert.equal(getFilePreview("test")?.scope, "unstaged")
|
||||
h.click("staged:b.ts", { ctrlKey: true })
|
||||
h.click("staged:c.ts", { ctrlKey: true })
|
||||
h.git.unstageGitFile(h.git.gitActionItems()[0])
|
||||
await until(() => h.git.gitBulkSelectedItemIds().size === 0)
|
||||
assert.deepEqual(fixture.requests[1], { action: "unstage", paths: ["b.ts", "c.ts"] })
|
||||
assert.deepEqual(h.git.gitActionItems().map(item => item.path), ["a.ts"])
|
||||
} finally { h.close() }
|
||||
})
|
||||
|
||||
test("pending mutation cannot replace a newly opened reader even without a sidebar click", async () => {
|
||||
for (const destination of [{ kind: "workspace" as const }, { commit: "abcdef" }, { path: "b.ts" }, { sessionId: "another-session" }]) {
|
||||
const h = await setup()
|
||||
try {
|
||||
h.click("unstaged:a.ts")
|
||||
const gate = deferred()
|
||||
fixture.mutationGate = gate.promise
|
||||
h.git.stageGitFile(h.item("unstaged:a.ts"))
|
||||
const newer = { ...getFilePreview("test")!, ...destination }
|
||||
openFilePreview("test", newer)
|
||||
gate.resolve()
|
||||
await until(() => h.git.gitSelectedItemId() === "staged:a.ts")
|
||||
assert.equal(getFilePreview("test"), newer)
|
||||
} finally { h.close() }
|
||||
}
|
||||
})
|
||||
|
||||
test("an eye-opened diff reconciles independently of the selected row, including later selection clicks", async () => {
|
||||
const h = await setup()
|
||||
try {
|
||||
h.select("unstaged:a.ts")
|
||||
h.eye("unstaged:b.ts")
|
||||
assert.equal(h.git.gitSelectedItemId(), "unstaged:a.ts")
|
||||
const gate = deferred()
|
||||
fixture.mutationGate = gate.promise
|
||||
h.git.stageGitFile(h.item("unstaged:b.ts"))
|
||||
h.select("unstaged:c.ts")
|
||||
gate.resolve()
|
||||
await until(() => getFilePreview("test")?.scope === "staged")
|
||||
assert.equal(h.git.gitSelectedItemId(), "unstaged:c.ts")
|
||||
assert.equal(getFilePreview("test")?.path, "b.ts")
|
||||
fixture.mutationGate = null
|
||||
h.git.unstageGitFile(h.item("staged:b.ts"))
|
||||
await until(() => getFilePreview("test")?.scope === "unstaged")
|
||||
assert.deepEqual(fixture.requests.map(request => request.paths), [["b.ts"], ["b.ts"]])
|
||||
fixture.entries = [entry("a.ts"), entry("c.ts")]
|
||||
await h.git.refreshGitStatus()
|
||||
assert.equal(getFilePreview("test"), null)
|
||||
assert.equal(h.git.gitSelectedItemId(), "unstaged:c.ts")
|
||||
} finally { h.close() }
|
||||
})
|
||||
|
||||
test("passive refresh and commits reconcile an eye-opened file without selecting it", async () => {
|
||||
const h = await setup()
|
||||
try {
|
||||
h.select("unstaged:a.ts")
|
||||
h.eye("unstaged:b.ts")
|
||||
fixture.entries = [entry("a.ts"), entry("b.ts", true)]
|
||||
invalidateFilesystemCaches("test")
|
||||
await until(() => getFilePreview("test")?.scope === "staged")
|
||||
assert.equal(h.git.gitSelectedItemId(), "unstaged:a.ts")
|
||||
h.git.setGitCommitMessage("Commit B")
|
||||
await h.git.submitGitCommit()
|
||||
assert.equal(getFilePreview("test"), null)
|
||||
assert.equal(h.git.gitSelectedItemId(), "unstaged:a.ts")
|
||||
} finally { h.close() }
|
||||
})
|
||||
|
|
@ -10,6 +10,7 @@ import { showToastNotification } from "../../../../lib/notifications"
|
|||
import { adaptSdkGitStatusEntries, buildGitChangeListItems } from "./git-changes-model"
|
||||
import { createDebouncedRefresh, filesystemInvalidationVersion, invalidateFilesystemCaches } from "../../../../lib/filesystem-events"
|
||||
import { backgroundReads } from "../../../../lib/background-read-queue"
|
||||
import { closeFilePreview, getFilePreview, openFilePreview } from "../../../../stores/files-preview"
|
||||
|
||||
type UseGitChangesOptions = {
|
||||
isActive: Accessor<boolean>
|
||||
|
|
@ -20,6 +21,7 @@ type UseGitChangesOptions = {
|
|||
isPhoneLayout: Accessor<boolean>
|
||||
promptInputApi: Accessor<PromptInputApi | null>
|
||||
closeGitList: () => void
|
||||
externalDiff?: boolean
|
||||
}
|
||||
|
||||
export function useGitChanges(options: UseGitChangesOptions) {
|
||||
|
|
@ -48,6 +50,10 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
let seenFilesystemInvalidation = filesystemInvalidationVersion(options.instanceId)
|
||||
|
||||
const gitListItems = createMemo(() => buildGitChangeListItems(gitStatusEntries()))
|
||||
const gitActionItems = createMemo(() => {
|
||||
const selected = gitBulkSelectedItemIds()
|
||||
return gitListItems().filter(item => selected.size > 0 ? selected.has(item.id) : item.id === gitSelectedItemId())
|
||||
})
|
||||
const gitActive = createMemo(() => options.isActive() && options.rightPanelTab() === "git-changes")
|
||||
const cancelGitReads = () => {
|
||||
lifecycle += 1
|
||||
|
|
@ -158,6 +164,33 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
return `${item.path}::${item.originalPath ?? ""}::${item.section}::${item.status}::${item.additions}::${item.deletions}`
|
||||
}
|
||||
|
||||
const captureGitSelection = () => {
|
||||
const selection = describeGitSelection(gitSelectedItemId())
|
||||
const target = options.externalDiff ? getFilePreview(options.instanceId) : null
|
||||
const preview = target && target.kind !== "workspace" && !target.commit &&
|
||||
target.slug === options.worktreeSlug() &&
|
||||
target.directory === gitLocation(target.slug).directory ? target : null
|
||||
return { selection, preview, version: gitDiffRequestVersion }
|
||||
}
|
||||
|
||||
const reconcileGitSelection = async (previous: ReturnType<typeof captureGitSelection>) => {
|
||||
// The eye and sidebar selection are independent. Reader navigation is fenced
|
||||
// by target identity; sidebar clicks are fenced by their selection version.
|
||||
const preview = previous.preview
|
||||
if (preview && getFilePreview(options.instanceId) === preview) {
|
||||
const candidates = gitListItems().filter(item => item.path === preview.path)
|
||||
const item = candidates.find(item => item.section === (preview.scope ?? "unstaged")) ?? candidates[0]
|
||||
if (item) {
|
||||
// A fresh target also reloads unchanged content on explicit refresh.
|
||||
openFilePreview(options.instanceId, { ...preview, path: item.path, originalPath: item.originalPath, scope: item.section })
|
||||
} else closeFilePreview(options.instanceId)
|
||||
}
|
||||
if (previous.version !== gitDiffRequestVersion) return
|
||||
const selected = resolveValidGitSelection(previous.selection)
|
||||
if (selected) await openGitFile(selected)
|
||||
else { setGitSelectedItemId(null); clearSelectedGitDiff() }
|
||||
}
|
||||
|
||||
const clearSelectedGitDiff = () => {
|
||||
setGitSelectedError(null)
|
||||
setGitSelectedBefore(null)
|
||||
|
|
@ -239,6 +272,7 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
diffController?.abort()
|
||||
const controller = diffController = new AbortController()
|
||||
setGitSelectedItemId(itemId)
|
||||
if (options.externalDiff) return
|
||||
setGitSelectedLoading(true)
|
||||
clearSelectedGitDiff()
|
||||
|
||||
|
|
@ -288,8 +322,8 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
if (gitCommitSubmitting()) return
|
||||
|
||||
const refresh = passiveGitRefresh = {}
|
||||
const refreshSelectionId = gitSelectedItemId()
|
||||
const previousSelection = describeGitSelection(gitSelectedItemId())
|
||||
const previous = captureGitSelection()
|
||||
const previousSelection = previous.selection
|
||||
const previousFingerprint = describeGitSelectionFingerprint(previousSelection.itemId)
|
||||
const hadSelectedDiff =
|
||||
previousSelection.itemId !== null &&
|
||||
|
|
@ -298,12 +332,10 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
try {
|
||||
if (!await loadGitStatus(true)) return
|
||||
if (passiveGitRefresh !== refresh || !gitActive()) return
|
||||
if (gitSelectedItemId() !== refreshSelectionId) return
|
||||
const nextSelection = resolveValidGitSelection(previousSelection)
|
||||
setGitSelectedItemId(nextSelection)
|
||||
|
||||
if (!nextSelection) {
|
||||
clearSelectedGitDiff()
|
||||
await reconcileGitSelection(previous)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -314,8 +346,8 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
previousFingerprint !== nextFingerprint ||
|
||||
previousSelection.itemId === nextSelection
|
||||
|
||||
if (shouldReloadSelectedDiff) {
|
||||
await openGitFile(nextSelection)
|
||||
if (shouldReloadSelectedDiff || previous.preview) {
|
||||
await reconcileGitSelection(previous)
|
||||
}
|
||||
} finally {
|
||||
if (passiveGitRefresh !== refresh) return
|
||||
|
|
@ -331,8 +363,7 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
const mutateGitFile = async (item: GitChangeListItem, action: "stage" | "unstage") => {
|
||||
const context = captureGitContext()
|
||||
if (!context.current()) return
|
||||
const currentSelection = describeGitSelection(gitSelectedItemId())
|
||||
const fallbackSelection = currentSelection.path === item.path ? currentSelection : describeGitSelection(item.id)
|
||||
const previous = captureGitSelection()
|
||||
const selectedIds = gitBulkSelectedItemIds()
|
||||
const selectedItems = gitListItems().filter((candidate) => selectedIds.has(candidate.id))
|
||||
const bulkTargets = selectedItems.filter((candidate) => candidate.section === item.section)
|
||||
|
|
@ -345,16 +376,11 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
await serverApi.unstageWorktreeGitPaths(options.instanceId, context.slug, { paths: targetPaths })
|
||||
}
|
||||
|
||||
if (!context.current()) { invalidateFilesystemCaches(options.instanceId); return }
|
||||
invalidateFilesystemCaches(options.instanceId)
|
||||
if (!context.current()) return
|
||||
if (!await loadGitStatus(true) || !context.current()) return
|
||||
clearGitBulkSelection()
|
||||
const nextSelection = resolveValidGitSelection(fallbackSelection)
|
||||
setGitSelectedItemId(nextSelection)
|
||||
if (nextSelection) {
|
||||
await openGitFile(nextSelection)
|
||||
} else {
|
||||
clearSelectedGitDiff()
|
||||
}
|
||||
await reconcileGitSelection(previous)
|
||||
} catch (error) {
|
||||
if (!context.current()) return
|
||||
showToastNotification({
|
||||
|
|
@ -393,18 +419,14 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
setGitCommitSubmitting(true)
|
||||
const operation = commitOperation = {}
|
||||
const draft = gitCommitMessage()
|
||||
const previous = captureGitSelection()
|
||||
try {
|
||||
await serverApi.commitWorktreeGitChanges(options.instanceId, context.slug, { message })
|
||||
if (!context.current()) { invalidateFilesystemCaches(options.instanceId); return }
|
||||
invalidateFilesystemCaches(options.instanceId)
|
||||
if (!context.current()) return
|
||||
if (gitCommitMessage() === draft) setGitCommitMessage("")
|
||||
if (!await loadGitStatus(true) || !context.current()) return
|
||||
const nextSelection = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
|
||||
setGitSelectedItemId(nextSelection)
|
||||
if (nextSelection) {
|
||||
await openGitFile(nextSelection)
|
||||
} else {
|
||||
clearSelectedGitDiff()
|
||||
}
|
||||
await reconcileGitSelection(previous)
|
||||
if (!context.current()) return
|
||||
showToastNotification({
|
||||
message: options.t("instanceShell.gitChanges.commit.success"),
|
||||
|
|
@ -423,14 +445,9 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
|
||||
const refreshGitStatus = async () => {
|
||||
const context = captureGitContext()
|
||||
const previous = captureGitSelection()
|
||||
if (!await loadGitStatus(true) || !context.current()) return
|
||||
const selected = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
|
||||
setGitSelectedItemId(selected)
|
||||
if (selected) {
|
||||
void openGitFile(selected)
|
||||
} else {
|
||||
clearSelectedGitDiff()
|
||||
}
|
||||
await reconcileGitSelection(previous)
|
||||
}
|
||||
|
||||
const insertGitChangeContext = (item: GitChangeListItem, selection: { startLine: number; endLine: number } | null) => {
|
||||
|
|
@ -474,7 +491,9 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
}
|
||||
if (previousGitChangesActivationKey === activationKey) return
|
||||
previousGitChangesActivationKey = activationKey
|
||||
void passiveRefreshGitStatus()
|
||||
// Cache-first: revisits reuse the status rows. Absence (cleared while the
|
||||
// Changes mode was inactive) or a previous load error revalidates.
|
||||
if (gitStatusEntries() === null || gitStatusError() !== null) void passiveRefreshGitStatus()
|
||||
}))
|
||||
|
||||
const filesystemRefresh = createDebouncedRefresh(() => void passiveRefreshGitStatus({ forceReloadSelectedDiff: true }))
|
||||
|
|
@ -501,6 +520,7 @@ export function useGitChanges(options: UseGitChangesOptions) {
|
|||
gitStatusError,
|
||||
gitSelectedItemId,
|
||||
gitBulkSelectedItemIds,
|
||||
gitActionItems,
|
||||
gitSelectedLoading,
|
||||
gitSelectedError,
|
||||
gitSelectedBefore,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
|
||||
import type { GitCommitDetails, GitHistoryPage } from "../../../../../../server/src/api-types"
|
||||
import { serverApi } from "../../../../lib/api-client"
|
||||
import { backgroundReads } from "../../../../lib/background-read-queue"
|
||||
import { createDebouncedRefresh, filesystemInvalidationVersion } from "../../../../lib/filesystem-events"
|
||||
|
||||
export function useGitHistory(instanceId: string, slug: Accessor<string>, active: Accessor<boolean>) {
|
||||
const [page, setPage] = createSignal<GitHistoryPage | null>(null)
|
||||
const [details, setDetails] = createSignal<GitCommitDetails | null>(null)
|
||||
const [selected, setSelected] = createSignal<string | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [detailLoading, setDetailLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
let listController: AbortController | undefined
|
||||
let detailController: AbortController | undefined
|
||||
let pending = false
|
||||
// Invalidations while the history mode is inactive only mark the cached page
|
||||
// stale; the refetch happens lazily on the next visit.
|
||||
|
||||
async function refresh(more = false) {
|
||||
if (!active()) return
|
||||
if (loading()) { pending = true; return }
|
||||
const controller = listController = new AbortController()
|
||||
const scope = slug(), previous = page()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await backgroundReads.run(controller.signal, () => serverApi.fetchGitHistory(instanceId, scope,
|
||||
more ? previous?.commits.length ?? 0 : 0, more ? previous?.head ?? undefined : undefined, controller.signal), "visible")
|
||||
if (controller.signal.aborted || scope !== slug()) return
|
||||
if (more && previous) setPage({ ...result, commits: [...previous.commits, ...result.commits] })
|
||||
else if (previous && previous.head === result.head && previous.commits.length > result.commits.length) {
|
||||
setPage({ ...result, commits: [...result.commits, ...previous.commits.slice(result.commits.length)], hasMore: previous.hasMore })
|
||||
} else setPage(result)
|
||||
} catch (cause) {
|
||||
if (!controller.signal.aborted) setError(String(cause instanceof Error ? cause.message : cause))
|
||||
} finally {
|
||||
if (listController === controller) {
|
||||
setLoading(false)
|
||||
if (pending) { pending = false; void refresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function select(id: string) {
|
||||
if (!active()) return
|
||||
detailController?.abort()
|
||||
const controller = detailController = new AbortController()
|
||||
setSelected(id)
|
||||
setDetails(null)
|
||||
setError(null)
|
||||
setDetailLoading(true)
|
||||
try {
|
||||
const result = await backgroundReads.run(controller.signal, () => serverApi.fetchGitCommit(instanceId, slug(), id, controller.signal), "visible")
|
||||
if (!controller.signal.aborted) setDetails(result)
|
||||
} catch (cause) {
|
||||
if (!controller.signal.aborted) setError(String(cause instanceof Error ? cause.message : cause))
|
||||
} finally { if (detailController === controller) setDetailLoading(false) }
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
listController?.abort()
|
||||
detailController?.abort()
|
||||
listController = detailController = undefined
|
||||
pending = false
|
||||
setLoading(false)
|
||||
setDetailLoading(false)
|
||||
}
|
||||
createEffect(on(slug, () => { cancel(); setPage(null); setDetails(null); setSelected(null); setError(null) }))
|
||||
let historyStale = false
|
||||
createEffect(on(() => active() ? slug() : null, value => {
|
||||
if (value === null) { cancel(); return }
|
||||
// Commit history is cache-first: file writes must not refetch it on every
|
||||
// visit. Mutations and explicit refreshes still revalidate.
|
||||
if (!page() || historyStale) { historyStale = false; void refresh() }
|
||||
if (selected() && !details()) void select(selected()!)
|
||||
}))
|
||||
const debounced = createDebouncedRefresh(() => void refresh())
|
||||
createEffect(on(() => filesystemInvalidationVersion(instanceId), () => {
|
||||
if (active()) debounced.trigger()
|
||||
else historyStale = true
|
||||
}, { defer: true }))
|
||||
onCleanup(() => { cancel(); debounced.cancel() })
|
||||
return { page, details, selected, loading, detailLoading, error, refresh, select,
|
||||
back: () => { detailController?.abort(); setSelected(null); setDetails(null); setError(null); setDetailLoading(false) } }
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { registerHooks } from "node:module"
|
||||
import { after, test } from "node:test"
|
||||
import type { FileSystemEntry } from "../../../../../../server/src/api-types"
|
||||
|
||||
const fixture = { listings: new Map<string, FileSystemEntry[]>(), reads: [] as string[], gates: new Map<string, Promise<void>>() }
|
||||
;(globalThis as any).__workspaceTreeFixture = fixture
|
||||
const hooks = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === "solid-js") return nextResolve("solid-js/dist/solid.js", context)
|
||||
if (context.parentURL?.endsWith("/useWorkspaceTree.ts") && specifier === "../../../../lib/api-client") {
|
||||
const source = `const f = globalThis.__workspaceTreeFixture;
|
||||
export const serverApi = { listWorkspaceFiles: async (_id, path, directory) => {
|
||||
const key = directory + ':' + path; f.reads.push(key);
|
||||
const entries = f.listings.get(key) ?? []; await f.gates.get(key); return entries;
|
||||
} };`
|
||||
return { shortCircuit: true, url: `data:text/javascript,${encodeURIComponent(source)}` }
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
},
|
||||
})
|
||||
|
||||
after(() => { hooks.deregister(); delete (globalThis as any).__workspaceTreeFixture })
|
||||
const { createRoot, createSignal } = await import("solid-js")
|
||||
const { useWorkspaceTree } = await import("./useWorkspaceTree")
|
||||
const { invalidateFilesystemCaches } = await import("../../../../lib/filesystem-events")
|
||||
const file = (path: string, type: "file" | "directory" = "file"): FileSystemEntry => ({ path, name: path.split("/").pop()!, type })
|
||||
async function until(predicate: () => boolean) {
|
||||
for (let i = 0; i < 100 && !predicate(); i++) await new Promise(resolve => setTimeout(resolve, 5))
|
||||
assert.ok(predicate(), "tree settled before timeout")
|
||||
}
|
||||
|
||||
for (const transition of ["worktree", "inactive"] as const) {
|
||||
test(`a fresh root cannot hide an expanded descendant cancelled by a ${transition} transition`, async () => {
|
||||
fixture.reads = []; fixture.gates.clear()
|
||||
fixture.listings = new Map([
|
||||
["/a:.", [file("src", "directory"), file("collapsed", "directory")]],
|
||||
["/a:src", [file("src/old.ts")]], ["/b:.", [file("b.txt")]],
|
||||
])
|
||||
let release!: () => void, close!: () => void
|
||||
const h = createRoot(dispose => {
|
||||
close = dispose
|
||||
const [directory, move] = createSignal("/a"), [active, activate] = createSignal(true)
|
||||
return { tree: useWorkspaceTree(`partial-${transition}`, directory, active), move, activate }
|
||||
})
|
||||
try {
|
||||
await until(() => h.tree.rows().some(row => row.path === "src"))
|
||||
h.tree.toggle("src")
|
||||
await until(() => h.tree.rows().some(row => row.path === "src/old.ts") && !h.tree.busy().size)
|
||||
fixture.listings.set("/a:src", [file("src/new.ts")])
|
||||
fixture.gates.set("/a:src", new Promise<void>(resolve => { release = resolve }))
|
||||
invalidateFilesystemCaches(`partial-${transition}`)
|
||||
await until(() => fixture.reads.filter(key => key === "/a:src").length === 2 && !h.tree.busy().has("."))
|
||||
if (transition === "worktree") {
|
||||
h.move("/b")
|
||||
await until(() => h.tree.rows().some(row => row.path === "b.txt"))
|
||||
} else h.activate(false)
|
||||
fixture.gates.clear(); release()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
if (transition === "worktree") h.move("/a")
|
||||
else h.activate(true)
|
||||
await until(() => h.tree.rows().some(row => row.path === "src/new.ts") && !h.tree.busy().size)
|
||||
assert.ok(!h.tree.rows().some(row => row.path === "src/old.ts"))
|
||||
assert.equal(fixture.reads.filter(key => key === "/a:src").length, 3)
|
||||
assert.ok(!fixture.reads.some(key => key.endsWith(":collapsed")), "collapsed subtrees stay outside read demand")
|
||||
} finally { release?.(); fixture.gates.clear(); close() }
|
||||
})
|
||||
}
|
||||
test("worktree revisits keep cache-first rows but revalidate snapshots invalidated while browsing elsewhere", async () => {
|
||||
fixture.reads = []
|
||||
fixture.listings = new Map([
|
||||
["/a:.", [file("old.txt"), file("src", "directory")]],
|
||||
["/a:src", [file("src/old.ts")]], ["/b:.", [file("b.txt")]],
|
||||
])
|
||||
let close!: () => void
|
||||
const h = createRoot(dispose => {
|
||||
close = dispose
|
||||
const [directory, move] = createSignal("/a")
|
||||
return { tree: useWorkspaceTree("tree-test", directory, () => true), move }
|
||||
})
|
||||
try {
|
||||
await until(() => h.tree.rows().some(row => row.path === "old.txt"))
|
||||
h.tree.toggle("src")
|
||||
await until(() => h.tree.rows().some(row => row.path === "src/old.ts"))
|
||||
h.move("/b")
|
||||
await until(() => h.tree.rows().some(row => row.path === "b.txt"))
|
||||
h.move("/a")
|
||||
await until(() => !h.tree.busy().size)
|
||||
assert.equal(fixture.reads.filter(key => key === "/a:.").length, 1, "fresh cache does not rescan")
|
||||
h.move("/b")
|
||||
fixture.listings.set("/a:.", [file("new.txt"), file("src", "directory")])
|
||||
fixture.listings.set("/a:src", [file("src/new.ts")])
|
||||
invalidateFilesystemCaches("tree-test")
|
||||
await until(() => fixture.reads.filter(key => key === "/b:.").length === 2 && !h.tree.busy().size)
|
||||
h.move("/a")
|
||||
assert.ok(h.tree.rows().some(row => row.path === "old.txt"), "cached rows display before lazy response")
|
||||
await until(() => h.tree.rows().some(row => row.path === "src/new.ts") && !h.tree.busy().size)
|
||||
assert.ok(h.tree.rows().some(row => row.path === "new.txt"))
|
||||
assert.ok(!h.tree.rows().some(row => row.path.includes("old")))
|
||||
assert.equal(fixture.reads.filter(key => key === "/a:.").length, 2)
|
||||
assert.equal(fixture.reads.filter(key => key === "/a:src").length, 2)
|
||||
} finally { close() }
|
||||
})
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { createEffect, createMemo, createSignal, on, onCleanup, type Accessor } from "solid-js"
|
||||
import type { FileSystemEntry } from "../../../../../../server/src/api-types"
|
||||
import { serverApi } from "../../../../lib/api-client"
|
||||
import { backgroundReads } from "../../../../lib/background-read-queue"
|
||||
import { createDebouncedRefresh, filesystemInvalidationVersion } from "../../../../lib/filesystem-events"
|
||||
|
||||
interface TreeState {
|
||||
directories: Map<string, FileSystemEntry[]>
|
||||
expanded: Set<string>
|
||||
selected: string | null
|
||||
}
|
||||
type TreeRow = FileSystemEntry & { depth: number; parent: string }
|
||||
export function useWorkspaceTree(instanceId: string, directory: Accessor<string>, active: Accessor<boolean>) {
|
||||
const empty = (): TreeState => ({ directories: new Map(), expanded: new Set(["."]), selected: null })
|
||||
const cache = new Map<string, { state: TreeState; versions: Map<string, number> }>()
|
||||
const [state, setState] = createSignal<TreeState>(empty())
|
||||
const [errors, setErrors] = createSignal(new Map<string, string>())
|
||||
const [busy, setBusy] = createSignal(new Set<string>())
|
||||
const requests = new Map<string, AbortController>()
|
||||
let current = directory()
|
||||
let dirty = false
|
||||
// Filesystem-invalidation version seen by each loaded directory. Reopening a
|
||||
// directory serves its cached children unless the filesystem changed since
|
||||
// they were read; collapsed subtrees are never re-read on expand.
|
||||
let loadedVersions = new Map<string, number>()
|
||||
|
||||
async function load(path: string, force = false) {
|
||||
if (!active() || requests.has(path) || (!force && state().directories.has(path))) return
|
||||
const scope = directory(), controller = new AbortController()
|
||||
const version = filesystemInvalidationVersion(instanceId)
|
||||
requests.set(path, controller)
|
||||
setBusy(new Set(requests.keys()))
|
||||
setErrors(previous => { const next = new Map(previous); next.delete(path); return next })
|
||||
try {
|
||||
const entries = await backgroundReads.run(controller.signal,
|
||||
() => serverApi.listWorkspaceFiles(instanceId, path, scope, controller.signal), "visible")
|
||||
if (controller.signal.aborted || directory() !== scope) return
|
||||
loadedVersions.set(path, version)
|
||||
setState(previous => ({ ...previous, directories: new Map(previous.directories).set(path, entries
|
||||
.slice().sort((a, b) => Number(b.type === "directory") - Number(a.type === "directory") || a.name.localeCompare(b.name))) }))
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) setErrors(previous => new Map(previous).set(path, error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
if (requests.get(path) === controller) { requests.delete(path); setBusy(new Set(requests.keys())) }
|
||||
}
|
||||
}
|
||||
function cancel() {
|
||||
requests.forEach(controller => controller.abort())
|
||||
requests.clear()
|
||||
setBusy(new Set<string>())
|
||||
}
|
||||
const directories = createMemo(() => state().directories)
|
||||
const expanded = createMemo(() => state().expanded)
|
||||
const rows = createMemo<TreeRow[]>((previous = []) => {
|
||||
const existing = new Map(previous.map(row => [row.path, row]))
|
||||
const result: TreeRow[] = []
|
||||
const visit = (path: string, depth: number) => {
|
||||
for (const entry of directories().get(path) ?? []) {
|
||||
const row = existing.get(entry.path)
|
||||
result.push(row && row.name === entry.name && row.type === entry.type && row.depth === depth && row.parent === path
|
||||
? row : { ...entry, depth, parent: path })
|
||||
if (entry.type === "directory" && expanded().has(entry.path)) visit(entry.path, depth + 1)
|
||||
}
|
||||
}
|
||||
visit(".", 1)
|
||||
return result
|
||||
})
|
||||
function toggle(path: string) {
|
||||
const opening = !state().expanded.has(path)
|
||||
setState(previous => {
|
||||
const expanded = new Set(previous.expanded)
|
||||
if (opening) expanded.add(path); else expanded.delete(path)
|
||||
return { ...previous, expanded }
|
||||
})
|
||||
if (!opening) return
|
||||
// Cache-first expand: reuse loaded children unless the filesystem changed
|
||||
// since they were read. Only the reopened visible subtree revalidates.
|
||||
if (loadedVersions.get(path) !== filesystemInvalidationVersion(instanceId)) {
|
||||
void load(path, true)
|
||||
for (const row of rows()) {
|
||||
if (row.type === "directory" && row.path.startsWith(`${path}/`) && state().expanded.has(row.path)) {
|
||||
void load(row.path, true)
|
||||
}
|
||||
}
|
||||
} else void load(path)
|
||||
}
|
||||
function refresh() {
|
||||
if (!active()) { dirty = true; return }
|
||||
if (requests.size) { dirty = true; return }
|
||||
dirty = false
|
||||
void load(".", true)
|
||||
for (const row of rows()) if (row.type === "directory" && state().expanded.has(row.path)) void load(row.path, true)
|
||||
}
|
||||
const debounced = createDebouncedRefresh(refresh)
|
||||
const hasStaleVisibleDirectories = () => {
|
||||
const version = filesystemInvalidationVersion(instanceId)
|
||||
return loadedVersions.get(".") !== version || rows().some(row =>
|
||||
row.type === "directory" && state().expanded.has(row.path) && loadedVersions.get(row.path) !== version)
|
||||
}
|
||||
createEffect(on(busy, value => { if (!value.size && dirty && active()) debounced.trigger() }))
|
||||
createEffect(on(directory, value => {
|
||||
cancel()
|
||||
debounced.cancel()
|
||||
cache.set(current, { state: state(), versions: loadedVersions })
|
||||
if (cache.size > 8) cache.delete(cache.keys().next().value!)
|
||||
current = value
|
||||
const cached = cache.get(value)
|
||||
loadedVersions = cached?.versions ?? new Map()
|
||||
setState(cached?.state ?? empty())
|
||||
setErrors(new Map())
|
||||
dirty = hasStaleVisibleDirectories()
|
||||
}))
|
||||
createEffect(on(() => active() ? directory() : null, value => {
|
||||
if (value === null) { cancel(); return }
|
||||
// Cache-first: a visited directory keeps its rows. Pending invalidations
|
||||
// revalidate through the debounced refresh below.
|
||||
if (!state().directories.has(".")) void load(".", true)
|
||||
else if (dirty || hasStaleVisibleDirectories()) refresh()
|
||||
}))
|
||||
createEffect(on(() => filesystemInvalidationVersion(instanceId), () => {
|
||||
dirty = true
|
||||
if (active()) debounced.trigger()
|
||||
}, { defer: true }))
|
||||
onCleanup(() => { cancel(); debounced.cancel() })
|
||||
return { rows, state, busy, errors, toggle, refresh, load,
|
||||
select: (path: string) => setState(previous => ({ ...previous, selected: path })) }
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ import { clearConversationPlaybackForSession } from "../../stores/conversation-s
|
|||
import { useConfig } from "../../stores/preferences"
|
||||
import { getSessionPreview } from "../../stores/session-previews"
|
||||
import { SessionPreviewView } from "../session-preview-view"
|
||||
import { FilesPreviewView } from "../files-preview-view"
|
||||
import { getFilePreview, closeFilePreview } from "../../stores/files-preview"
|
||||
import { isSnapshotAutoFollowing } from "../virtual-follow-behavior"
|
||||
import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent, type SessionBottomPinIntent } from "./session-bottom-pin-intent"
|
||||
import { focusConversationStream } from "../focus-conversation"
|
||||
|
|
@ -100,6 +102,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
.filter((item): item is SessionInboxUser => item.type === "user"))
|
||||
const pendingPromptById = createMemo(() => new Map(pendingUserPrompts().map((item) => [item.id, item])))
|
||||
const preview = createMemo(() => getSessionPreview(props.sessionId, props.instanceFolder))
|
||||
const filePreview = createMemo(() => {
|
||||
const target = getFilePreview(props.instanceId)
|
||||
return target?.sessionId === props.sessionId ? target : null
|
||||
})
|
||||
createEffect(() => { if (props.isActive && preview()?.mode === "preview") closeFilePreview(props.instanceId) })
|
||||
|
||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||
|
||||
|
|
@ -605,6 +612,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
}
|
||||
>
|
||||
<div ref={rootRef} class="session-view">
|
||||
<Show when={filePreview()} fallback={
|
||||
<Show
|
||||
when={preview()?.mode === "preview"}
|
||||
fallback={
|
||||
|
|
@ -648,6 +656,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
onInsertComment={handleInsertPreviewComment}
|
||||
/>
|
||||
</Show>
|
||||
}>{target => <FilesPreviewView instanceId={props.instanceId} target={target()} active={Boolean(props.isActive)} onClose={() => closeFilePreview(props.instanceId)} onInsertComment={handleInsertPreviewComment} />}</Show>
|
||||
|
||||
<Show when={attachments().length > 0}>
|
||||
<PromptAttachmentsBar
|
||||
|
|
|
|||
128
packages/ui/src/components/workspace-file-editor.test.ts
Normal file
128
packages/ui/src/components/workspace-file-editor.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { registerHooks } from "node:module"
|
||||
import { after, test } from "node:test"
|
||||
import type { FilePreviewTarget } from "../stores/files-preview"
|
||||
|
||||
const fixture = { disk: "baseline", gate: null as Promise<void> | null, writing: false, fail: false }
|
||||
;(globalThis as any).__workspaceEditorFixture = fixture
|
||||
const hooks = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === "solid-js") return nextResolve("solid-js/dist/solid.js", context)
|
||||
const sources: Record<string, string> = {
|
||||
"../lib/api-client": `const f = globalThis.__workspaceEditorFixture;
|
||||
export const serverApi = {
|
||||
previewWorkspaceFile: async () => ({ contents: f.disk }),
|
||||
writeWorkspaceFile: async (_id, _path, value) => {
|
||||
f.writing = true; await f.gate;
|
||||
if (f.fail) throw new Error('write failed'); f.disk = value;
|
||||
},
|
||||
};`,
|
||||
"../lib/notifications": "export const showToastNotification = () => {}",
|
||||
"../stores/alerts": "export const showConfirmDialog = async () => true",
|
||||
}
|
||||
if (context.parentURL?.endsWith("/workspace-file-editor.ts") && sources[specifier]) {
|
||||
return { shortCircuit: true, url: `data:text/javascript,${encodeURIComponent(sources[specifier])}` }
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
},
|
||||
})
|
||||
after(() => { hooks.deregister(); delete (globalThis as any).__workspaceEditorFixture })
|
||||
const { createRoot } = await import("solid-js")
|
||||
const { useWorkspaceFileEditor } = await import("./workspace-file-editor")
|
||||
|
||||
function setup(path: string) {
|
||||
const target: FilePreviewTarget = { kind: "workspace", sessionId: "session", slug: "root", directory: "/repo", path }
|
||||
let close!: () => void
|
||||
const editor = createRoot(dispose => {
|
||||
close = dispose
|
||||
return useWorkspaceFileEditor({ instanceId: "test", target: () => target, active: () => true,
|
||||
t: key => key, onError: () => {} })
|
||||
})
|
||||
return { editor, close }
|
||||
}
|
||||
|
||||
for (const transition of ["mounted", "reopen-after", "reopen-before"] as const) {
|
||||
test(`undo to the clean baseline during a save retains the resulting draft (${transition})`, async () => {
|
||||
fixture.disk = "baseline"; fixture.writing = false; fixture.fail = false
|
||||
let release!: () => void
|
||||
fixture.gate = new Promise<void>(resolve => { release = resolve })
|
||||
const path = `undo-${transition}.txt`
|
||||
const h = setup(path)
|
||||
let earlyReader: ReturnType<typeof setup> | undefined
|
||||
try {
|
||||
h.editor.adopt("baseline")
|
||||
h.editor.change("saved edit")
|
||||
const pending = h.editor.save()
|
||||
await Promise.resolve()
|
||||
assert.equal(fixture.writing, true)
|
||||
h.editor.change("baseline")
|
||||
assert.equal(h.editor.dirty(), false)
|
||||
if (transition !== "mounted") h.close()
|
||||
if (transition === "reopen-before") {
|
||||
earlyReader = setup(path)
|
||||
assert.equal(earlyReader.editor.reset(), "baseline")
|
||||
assert.equal(earlyReader.editor.saving(), true, "reopened reader fences authoritative reload during the write")
|
||||
}
|
||||
release(); await pending
|
||||
assert.equal(fixture.disk, "saved edit")
|
||||
if (transition === "mounted") {
|
||||
assert.equal(h.editor.text(), "baseline")
|
||||
assert.equal(h.editor.dirty(), true)
|
||||
}
|
||||
if (earlyReader) {
|
||||
assert.equal(earlyReader.editor.text(), "baseline")
|
||||
assert.equal(earlyReader.editor.dirty(), true, "completion publishes the new baseline to the reopened reader")
|
||||
assert.equal(earlyReader.editor.saving(), false)
|
||||
}
|
||||
const reopened = earlyReader ?? setup(path)
|
||||
try {
|
||||
if (!earlyReader) assert.equal(reopened.editor.reset(), "baseline")
|
||||
assert.equal(reopened.editor.dirty(), true)
|
||||
fixture.gate = null
|
||||
await reopened.editor.save()
|
||||
assert.equal(fixture.disk, "baseline")
|
||||
assert.equal(reopened.editor.dirty(), false)
|
||||
} finally { reopened.close() }
|
||||
} finally { release(); earlyReader?.close(); h.close() }
|
||||
})
|
||||
}
|
||||
|
||||
test("a reopened reader shares pending-save edits and observes clean completion without an obsolete baseline", async () => {
|
||||
for (const newerText of ["saved edit", "newer edit"]) {
|
||||
fixture.disk = "baseline"; fixture.writing = false; fixture.fail = false
|
||||
let release!: () => void
|
||||
fixture.gate = new Promise<void>(resolve => { release = resolve })
|
||||
const path = `reopened-${newerText}.txt`, h = setup(path)
|
||||
let reopened: ReturnType<typeof setup> | undefined
|
||||
try {
|
||||
h.editor.adopt("baseline"); h.editor.change("saved edit")
|
||||
const pending = h.editor.save()
|
||||
await Promise.resolve(); h.close()
|
||||
reopened = setup(path)
|
||||
assert.equal(reopened.editor.reset(), "saved edit")
|
||||
reopened.editor.change(newerText)
|
||||
release(); await pending
|
||||
assert.equal(reopened.editor.text(), newerText)
|
||||
assert.equal(reopened.editor.dirty(), newerText !== "saved edit")
|
||||
assert.equal(reopened.editor.saving(), false)
|
||||
reopened.editor.discard()
|
||||
} finally { release(); reopened?.close(); h.close() }
|
||||
}
|
||||
})
|
||||
|
||||
test("a failed write removes a temporarily clean revert rather than retaining a false dirty draft", async () => {
|
||||
fixture.disk = "baseline"; fixture.writing = false; fixture.fail = true
|
||||
let release!: () => void
|
||||
fixture.gate = new Promise<void>(resolve => { release = resolve })
|
||||
const h = setup("failure.txt")
|
||||
try {
|
||||
h.editor.adopt("baseline"); h.editor.change("saved edit")
|
||||
const pending = h.editor.save()
|
||||
await Promise.resolve()
|
||||
h.editor.change("baseline")
|
||||
release(); await pending
|
||||
assert.equal(h.editor.dirty(), false)
|
||||
const reopened = setup("failure.txt")
|
||||
try { assert.equal(reopened.editor.reset(), undefined) } finally { reopened.close() }
|
||||
} finally { fixture.fail = false; release(); h.close() }
|
||||
})
|
||||
97
packages/ui/src/components/workspace-file-editor.ts
Normal file
97
packages/ui/src/components/workspace-file-editor.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { createSignal, onCleanup, type Accessor, type Setter } from "solid-js"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { invalidateFilesystemCaches } from "../lib/filesystem-events"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import type { FilePreviewTarget } from "../stores/files-preview"
|
||||
|
||||
// Retain unsaved text when the reader closes or another file/session is opened.
|
||||
// Readers of the same draft share its baseline and pending-write state, so a
|
||||
// reopened reader cannot reload over a revert before the old save completes.
|
||||
type DraftValue = { original: string; text: string; pending: boolean }
|
||||
type Draft = { value: Accessor<DraftValue>; update: Setter<DraftValue> }
|
||||
const drafts = new Map<string, Draft>()
|
||||
function createDraft(original: string, text: string): Draft {
|
||||
const [value, update] = createSignal<DraftValue>({ original, text, pending: false })
|
||||
return { value, update }
|
||||
}
|
||||
export const decodeWorkspaceText = (result: { encoding?: string; contents: string }) => result.encoding === "base64"
|
||||
? new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(atob(result.contents), char => char.charCodeAt(0)))
|
||||
: result.contents
|
||||
|
||||
export function useWorkspaceFileEditor(options: {
|
||||
instanceId: string; target: Accessor<FilePreviewTarget>; active: Accessor<boolean>
|
||||
t: (key: string, vars?: Record<string, any>) => string; onError: (message: string) => void
|
||||
}) {
|
||||
const [localText, setText] = createSignal("")
|
||||
const [localOriginal, setOriginal] = createSignal<string | undefined>()
|
||||
const [localSaving, setSaving] = createSignal(false)
|
||||
const [draft, setDraft] = createSignal<Draft>()
|
||||
const text = () => draft()?.value().text ?? localText()
|
||||
const original = () => draft()?.value().original ?? localOriginal()
|
||||
const saving = () => localSaving() || (draft()?.value().pending ?? false)
|
||||
const dirty = () => original() !== undefined && text() !== original()
|
||||
const key = () => JSON.stringify([options.instanceId, options.target().directory, options.target().path])
|
||||
let generation = 0, disposed = false
|
||||
onCleanup(() => { disposed = true; generation += 1 })
|
||||
function reset() {
|
||||
generation += 1
|
||||
setSaving(false)
|
||||
const retained = drafts.get(key())
|
||||
setDraft(retained)
|
||||
setOriginal(retained?.value().original)
|
||||
setText(retained?.value().text ?? "")
|
||||
return retained?.value().text
|
||||
}
|
||||
function adopt(value: string) { setDraft(undefined); setOriginal(value); setText(value) }
|
||||
function change(value: string) {
|
||||
const baseline = original()
|
||||
setText(value)
|
||||
if (baseline === undefined) return
|
||||
const retained = draft() ?? createDraft(baseline, value)
|
||||
retained.update(previous => ({ ...previous, text: value }))
|
||||
setDraft(retained)
|
||||
if (dirty() || retained.value().pending) drafts.set(key(), retained)
|
||||
else drafts.delete(key())
|
||||
}
|
||||
function discard() { drafts.delete(key()); setDraft(undefined); setOriginal(undefined) }
|
||||
async function save(value = text()) {
|
||||
if (saving() || !options.active() || original() === undefined || !dirty()) return
|
||||
const target = options.target(), identity = key(), version = generation, baseline = original()
|
||||
const current = () => !disposed && version === generation && target === options.target() && options.active()
|
||||
const writing = draft()!
|
||||
writing.update(previous => ({ ...previous, pending: true }))
|
||||
setSaving(true)
|
||||
try {
|
||||
// An unavailable conflict check must never silently permit an overwrite.
|
||||
const disk = decodeWorkspaceText(await serverApi.previewWorkspaceFile(options.instanceId, target.path, target.directory))
|
||||
if (!current()) return
|
||||
if (disk !== baseline && disk !== value) {
|
||||
const confirmed = await showConfirmDialog(options.t("instanceShell.rightPanel.actions.conflict.message", { path: target.path }), {
|
||||
variant: "warning", confirmLabel: options.t("instanceShell.rightPanel.actions.conflict.confirmLabel"),
|
||||
cancelLabel: options.t("instanceShell.rightPanel.actions.conflict.cancelLabel"), dismissible: true,
|
||||
})
|
||||
if (!confirmed || !current()) return
|
||||
}
|
||||
if (disk !== value) await serverApi.writeWorkspaceFile(options.instanceId, target.path, value, { directory: target.directory })
|
||||
const latest = drafts.get(identity)
|
||||
if (latest) {
|
||||
latest.update(previous => ({ ...previous, original: value }))
|
||||
if (latest.value().text === value) drafts.delete(identity)
|
||||
}
|
||||
if (current()) {
|
||||
setOriginal(value)
|
||||
showToastNotification({ message: options.t("instanceShell.rightPanel.toast.saveSuccess"), variant: "success" })
|
||||
}
|
||||
invalidateFilesystemCaches(options.instanceId)
|
||||
} catch (error) {
|
||||
if (current()) options.onError(error instanceof Error ? error.message : options.t("instanceShell.rightPanel.toast.saveError"))
|
||||
} finally {
|
||||
writing.update(previous => ({ ...previous, pending: false }))
|
||||
const latest = drafts.get(identity)
|
||||
if (latest && latest.value().text === latest.value().original && !latest.value().pending) drafts.delete(identity)
|
||||
if (!disposed && generation === version) setSaving(false)
|
||||
}
|
||||
}
|
||||
return { text, dirty, saving, reset, adopt, change, discard, save }
|
||||
}
|
||||
110
packages/ui/src/components/workspace-file-view.tsx
Normal file
110
packages/ui/src/components/workspace-file-view.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { Show, Suspense, createEffect, createSignal, lazy, on, onCleanup } from "solid-js"
|
||||
import { Save, WrapText, X } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { previewReads } from "../lib/background-read-queue"
|
||||
import { loadMonaco } from "../lib/monaco/setup"
|
||||
import { createDebouncedRefresh, filesystemInvalidationVersion } from "../lib/filesystem-events"
|
||||
import type { FilePreviewTarget } from "../stores/files-preview"
|
||||
import { Markdown } from "./markdown"
|
||||
import { writeClientLayoutValue } from "../stores/client-state"
|
||||
import { readStoredEnum, RIGHT_PANEL_FILES_WORD_WRAP_KEY } from "./instance/shell/storage"
|
||||
import { decodeWorkspaceText, useWorkspaceFileEditor } from "./workspace-file-editor"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
|
||||
const MonacoFileViewer = lazy(() => import("./file-viewer/monaco-file-viewer").then(module => ({ default: module.MonacoFileViewer })))
|
||||
const imageTypes: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif", svg: "image/svg+xml", ico: "image/x-icon", bmp: "image/bmp", avif: "image/avif" }
|
||||
|
||||
export function WorkspaceFileView(props: { instanceId: string; target: FilePreviewTarget; active: boolean; onClose: () => void }) {
|
||||
const { t } = useI18n(), { isDark } = useTheme()
|
||||
const [content, setContent] = createSignal<{ text?: string; image?: string; binary?: boolean } | null>(null)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [source, setSource] = createSignal(false)
|
||||
const [wrap, setWrap] = createSignal(readStoredEnum(RIGHT_PANEL_FILES_WORD_WRAP_KEY, ["on", "off"] as const) === "on")
|
||||
const editor = useWorkspaceFileEditor({ instanceId: props.instanceId, target: () => props.target, active: () => props.active, t, onError: setError })
|
||||
createEffect(() => writeClientLayoutValue(RIGHT_PANEL_FILES_WORD_WRAP_KEY, wrap() ? "on" : "off"))
|
||||
let controller: AbortController | undefined
|
||||
let pending = false
|
||||
const markdown = () => /\.(md|markdown)$/i.test(props.target.path)
|
||||
async function load() {
|
||||
if (!props.active || editor.dirty() || editor.saving()) return
|
||||
if (loading()) { pending = true; return }
|
||||
const request = controller = new AbortController(), target = props.target
|
||||
if (!markdown() && !imageTypes[target.path.split(".").pop()!.toLowerCase()]) void loadMonaco().catch(() => {})
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await previewReads.run(request.signal,
|
||||
() => serverApi.previewWorkspaceFile(props.instanceId, target.path, target.directory, request.signal), "visible")
|
||||
if (request.signal.aborted || editor.dirty() || editor.saving()) return
|
||||
const mime = imageTypes[target.path.split(".").pop()!.toLowerCase()]
|
||||
if (mime && result.encoding === "base64") setContent({ image: `data:${mime};base64,${result.contents}` })
|
||||
else {
|
||||
try {
|
||||
const text = decodeWorkspaceText(result)
|
||||
if (text.includes("\0")) setContent({ binary: true })
|
||||
else { editor.adopt(text); setContent({ text }) }
|
||||
} catch { setContent({ binary: true }) }
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!request.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause))
|
||||
} finally {
|
||||
if (controller === request) {
|
||||
setLoading(false)
|
||||
if (pending) { pending = false; void load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
function cancel() { controller?.abort(); controller = undefined; pending = false; setLoading(false) }
|
||||
createEffect(on(() => props.target, () => {
|
||||
cancel(); setContent(null); setError(null); setSource(false)
|
||||
const draft = editor.reset()
|
||||
if (draft !== undefined) setContent({ text: draft })
|
||||
else void load()
|
||||
}))
|
||||
createEffect(on(() => props.active, active => { if (!active) cancel(); else void load() }, { defer: true }))
|
||||
const refresh = createDebouncedRefresh(() => void load())
|
||||
createEffect(on(() => filesystemInvalidationVersion(props.instanceId), () => { if (props.active) refresh.trigger() }, { defer: true }))
|
||||
onCleanup(() => { cancel(); refresh.cancel() })
|
||||
async function explicitRefresh() {
|
||||
const target = props.target
|
||||
if (editor.dirty()) {
|
||||
const confirmed = await showConfirmDialog(t("instanceShell.rightPanel.actions.refreshDirty.message"), {
|
||||
variant: "warning", confirmLabel: t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"), cancelLabel: t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"),
|
||||
})
|
||||
if (!confirmed || target !== props.target || !props.active) return
|
||||
editor.discard()
|
||||
}
|
||||
await load()
|
||||
}
|
||||
return <section class="git-diff-view workspace-file-view" aria-label={t("filesPanel.viewer")}>
|
||||
<header class="window-header git-diff-header">
|
||||
<div class="git-diff-heading"><strong title={props.target.path}>{props.target.path}</strong>
|
||||
<span title={props.target.directory}>{props.target.directory}<Show when={editor.dirty()}> · ●</Show><Show when={content()?.text === undefined}> · {t("filesPanel.readOnly")}</Show></span></div>
|
||||
<button class="file-viewer-toolbar-button" disabled={loading() || editor.saving()} onClick={() => void explicitRefresh()}>{t("instanceShell.rightPanel.actions.refresh")}</button>
|
||||
<Show when={markdown() && content()?.text !== undefined}>
|
||||
<button class="file-viewer-toolbar-button" onClick={() => setSource(!source())}>{t(source() ? "instanceShell.filesShell.previewMarkdown" : "instanceShell.filesShell.showSource")}</button>
|
||||
</Show>
|
||||
<Show when={content()?.text !== undefined && (!markdown() || source())}>
|
||||
<button class="files-header-icon-button" aria-label={t("instanceShell.rightPanel.actions.save")} title={t("instanceShell.rightPanel.actions.save")} disabled={!editor.dirty() || editor.saving()} onClick={() => void editor.save()}><Save size={16} /></button>
|
||||
<button class="files-header-icon-button icon-toggle" aria-label={t(wrap() ? "instanceShell.filesShell.disableWordWrap" : "instanceShell.filesShell.enableWordWrap")} aria-pressed={wrap()} onClick={() => setWrap(!wrap())}><WrapText size={16} /></button>
|
||||
</Show>
|
||||
<button class="files-header-icon-button" aria-label={t("gitPanel.backChat")} title={t("gitPanel.backChat")} onClick={props.onClose}><X size={16} /></button>
|
||||
</header>
|
||||
<Show when={error()}><div class="p-3 text-error" role="alert">{error()} <button onClick={() => void explicitRefresh()}>{t("instanceShell.rightPanel.actions.refresh")}</button></div></Show>
|
||||
<Show when={loading() && !content()}><p class="p-3 text-secondary">{t("instanceInfo.loading")}</p></Show>
|
||||
<Show when={content()}>{value => <>
|
||||
<Show when={value().image}>{url => <div class="workspace-image"><img src={url()} alt={props.target.path} /></div>}</Show>
|
||||
<Show when={value().binary}><div class="p-3 text-secondary">{t("filesPanel.binary")}</div></Show>
|
||||
<Show when={value().text !== undefined}>
|
||||
<Show when={markdown() && !source()} fallback={<div class="git-diff-content"><Suspense>
|
||||
<MonacoFileViewer scopeKey={`${props.instanceId}:${props.target.directory}:workspace-editor`} path={props.target.path} content={editor.text()} wordWrap={wrap() ? "on" : "off"} onContentChange={editor.change} onSave={value => void editor.save(value)} />
|
||||
</Suspense></div>}>
|
||||
<div class="workspace-markdown"><Markdown part={{ type: "text", text: editor.text() }} isDark={isDark()} escapeRawHtml /></div>
|
||||
</Show>
|
||||
</Show>
|
||||
</>}</Show>
|
||||
</section>
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { HistoryQuery, HistoryResult, PruneBatch, PruneBatchResult } from "../../../server/src/opencode/session-pruning/history-contract"
|
||||
import type { GitHistoryPage, GitCommitDetails, GitCommitDiff } from "../../../server/src/api-types"
|
||||
import type { NavigationTarget, NavigationWindowResult, OutlineResult, OutlinePreviewResult, OutlineCheckpoint } from "../../../server/src/opencode/session-pruning/navigation-contract"
|
||||
import type {
|
||||
PruneRequest,
|
||||
|
|
@ -366,10 +367,10 @@ export const serverApi = {
|
|||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
listWorkspaceFiles(id: string, relativePath = ".", directory?: string): Promise<FileSystemEntry[]> {
|
||||
listWorkspaceFiles(id: string, relativePath = ".", directory?: string, signal?: AbortSignal): Promise<FileSystemEntry[]> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
if (directory) params.set("directory", directory)
|
||||
return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`)
|
||||
return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`, { signal })
|
||||
},
|
||||
searchWorkspaceFiles(
|
||||
id: string,
|
||||
|
|
@ -394,6 +395,10 @@ export const serverApi = {
|
|||
)
|
||||
return opts?.signal ? retryFileSearch(search, opts.signal) : search()
|
||||
},
|
||||
previewWorkspaceFile(id: string, relativePath: string, directory: string, signal?: AbortSignal): Promise<WorkspaceFileResponse> {
|
||||
const params = new URLSearchParams({ path: relativePath, directory })
|
||||
return request(`/api/workspaces/${encodeURIComponent(id)}/files/preview?${params}`, { signal })
|
||||
},
|
||||
readWorkspaceFile(id: string, relativePath: string, options?: { encoding?: "utf-8" | "base64" }): Promise<WorkspaceFileResponse> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
if (options?.encoding) {
|
||||
|
|
@ -403,8 +408,9 @@ export const serverApi = {
|
|||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
writeWorkspaceFile(id: string, relativePath: string, contents: string, options?: { worktree?: string }): Promise<void> {
|
||||
writeWorkspaceFile(id: string, relativePath: string, contents: string, options?: { worktree?: string; directory?: string }): Promise<void> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
if (options?.directory) params.set("directory", options.directory)
|
||||
if (options?.worktree && options.worktree !== "root") {
|
||||
params.set("worktree", options.worktree)
|
||||
}
|
||||
|
|
@ -422,6 +428,16 @@ export const serverApi = {
|
|||
{ signal },
|
||||
)
|
||||
},
|
||||
fetchGitHistory(id: string, slug: string, offset = 0, head?: string, signal?: AbortSignal): Promise<GitHistoryPage> {
|
||||
const query = new URLSearchParams({ offset: String(offset), ...(head ? { head } : {}) })
|
||||
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-history?${query}`, { signal })
|
||||
},
|
||||
fetchGitCommit(id: string, slug: string, commit: string, signal?: AbortSignal): Promise<GitCommitDetails> {
|
||||
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-history/${encodeURIComponent(commit)}`, { signal })
|
||||
},
|
||||
fetchGitCommitDiff(id: string, slug: string, commit: string, path: string, signal?: AbortSignal): Promise<GitCommitDiff> {
|
||||
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-history/${encodeURIComponent(commit)}?${new URLSearchParams({ path })}`, { signal })
|
||||
},
|
||||
fetchWorktreeGitDiff(id: string, slug: string, requestPayload: WorktreeGitDiffRequest, signal?: AbortSignal): Promise<WorktreeGitDiffResponse> {
|
||||
const params = new URLSearchParams({ path: requestPayload.path, scope: requestPayload.scope })
|
||||
if (requestPayload.originalPath) {
|
||||
|
|
|
|||
|
|
@ -40,3 +40,7 @@ export class BackgroundReadQueue {
|
|||
}
|
||||
|
||||
export const backgroundReads = new BackgroundReadQueue(2)
|
||||
|
||||
// A clicked file is foreground work: one reserved lane prevents long directory
|
||||
// and status scans from delaying the reader, while retaining connection headroom.
|
||||
export const previewReads = new BackgroundReadQueue(1)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,27 @@ export const instanceMessages = {
|
|||
"instanceShell.empty.description": "Senden Sie eine Nachricht, um eine neue Sitzung zu erstellen, oder wählen Sie eine bestehende Sitzung aus.",
|
||||
|
||||
"instanceShell.rightPanel.title": "Status-Panel",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git-Änderungen",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git",
|
||||
"gitPanel.history": "Verlauf",
|
||||
"filesPanel.workspace": "Arbeitsbereich",
|
||||
"filesPanel.commits": "Commits",
|
||||
"filesPanel.viewer": "Dateivorschau",
|
||||
"filesPanel.readOnly": "Schreibgeschützt",
|
||||
"filesPanel.binary": "Keine Vorschau für diese Binärdatei verfügbar.",
|
||||
"filesPanel.image.before": "Vorher",
|
||||
"filesPanel.image.after": "Nachher",
|
||||
"gitPanel.changes": "Änderungen",
|
||||
"gitPanel.view": "Git-Ansicht",
|
||||
"gitPanel.worktree": "Worktree ansehen",
|
||||
"gitPanel.detached": "Losgelöster HEAD",
|
||||
"gitPanel.filter": "Geladene Commits filtern…",
|
||||
"gitPanel.emptyHistory": "Keine Commits vorhanden",
|
||||
"gitPanel.more": "Ältere Commits laden",
|
||||
"gitPanel.files": "{count} geänderte Dateien",
|
||||
"gitPanel.actions": "Git-Aktionen",
|
||||
"gitPanel.diff": "Git-Diff",
|
||||
"gitPanel.parentDiff": "Vergleich mit erstem Eltern-Commit",
|
||||
"gitPanel.backChat": "Zurück zum Gespräch",
|
||||
"instanceShell.rightPanel.tabs.files": "Dateien",
|
||||
"instanceShell.rightPanel.tabs.status": "Status",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Tabs im rechten Panel",
|
||||
|
|
@ -161,6 +181,8 @@ export const instanceMessages = {
|
|||
"instanceShell.gitChanges.sections.unstaged": "Änderungen",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Zum Prompt hinzufügen",
|
||||
"instanceShell.gitChanges.actions.stage": "Datei stagen",
|
||||
"instanceShell.gitChanges.actions.stageSelected": "{count} ausgewählte Dateien stagen",
|
||||
"instanceShell.gitChanges.actions.unstageSelected": "{count} ausgewählte Dateien unstagen",
|
||||
"instanceShell.gitChanges.actions.unstage": "Datei unstagen",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Commit-Nachricht eingeben",
|
||||
"instanceShell.gitChanges.commit.submit": "Commit erstellen",
|
||||
|
|
|
|||
|
|
@ -89,7 +89,27 @@ export const instanceMessages = {
|
|||
"instanceShell.empty.description": "Send a message below to create a new session, or select an existing session to continue.",
|
||||
|
||||
"instanceShell.rightPanel.title": "Status Panel",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git Changes",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git",
|
||||
"gitPanel.history": "History",
|
||||
"filesPanel.workspace": "Workspace",
|
||||
"filesPanel.commits": "Commits",
|
||||
"filesPanel.viewer": "File preview",
|
||||
"filesPanel.readOnly": "Read-only",
|
||||
"filesPanel.binary": "Preview unavailable for this binary file.",
|
||||
"filesPanel.image.before": "Before",
|
||||
"filesPanel.image.after": "After",
|
||||
"gitPanel.changes": "Changes",
|
||||
"gitPanel.view": "Git view",
|
||||
"gitPanel.worktree": "Worktree to browse",
|
||||
"gitPanel.detached": "Detached HEAD",
|
||||
"gitPanel.filter": "Filter loaded commits…",
|
||||
"gitPanel.emptyHistory": "No commits to display",
|
||||
"gitPanel.more": "Load older commits",
|
||||
"gitPanel.files": "{count} changed files",
|
||||
"gitPanel.actions": "Git actions",
|
||||
"gitPanel.diff": "Git diff",
|
||||
"gitPanel.parentDiff": "Compared with first parent",
|
||||
"gitPanel.backChat": "Back to conversation",
|
||||
"instanceShell.rightPanel.tabs.files": "Files",
|
||||
"instanceShell.rightPanel.tabs.status": "Status",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
|
||||
|
|
@ -161,6 +181,8 @@ export const instanceMessages = {
|
|||
"instanceShell.gitChanges.sections.unstaged": "Changes",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Add to prompt",
|
||||
"instanceShell.gitChanges.actions.stage": "Stage file",
|
||||
"instanceShell.gitChanges.actions.stageSelected": "Stage {count} selected files",
|
||||
"instanceShell.gitChanges.actions.unstageSelected": "Unstage {count} selected files",
|
||||
"instanceShell.gitChanges.actions.unstage": "Unstage file",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Enter commit message",
|
||||
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||
|
|
|
|||
|
|
@ -89,7 +89,27 @@ export const instanceMessages = {
|
|||
"instanceShell.empty.description": "Envía un mensaje abajo para crear una nueva sesión, o selecciona una sesión existente para continuar.",
|
||||
|
||||
"instanceShell.rightPanel.title": "Panel de estado",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Cambios de Git",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git",
|
||||
"gitPanel.history": "Historial",
|
||||
"filesPanel.workspace": "Espacio de trabajo",
|
||||
"filesPanel.commits": "Commits",
|
||||
"filesPanel.viewer": "Vista previa del archivo",
|
||||
"filesPanel.readOnly": "Solo lectura",
|
||||
"filesPanel.binary": "Vista previa no disponible para este archivo binario.",
|
||||
"filesPanel.image.before": "Antes",
|
||||
"filesPanel.image.after": "Después",
|
||||
"gitPanel.changes": "Cambios",
|
||||
"gitPanel.view": "Vista Git",
|
||||
"gitPanel.worktree": "Worktree para consultar",
|
||||
"gitPanel.detached": "HEAD separado",
|
||||
"gitPanel.filter": "Filtrar commits cargados…",
|
||||
"gitPanel.emptyHistory": "No hay commits para mostrar",
|
||||
"gitPanel.more": "Cargar commits anteriores",
|
||||
"gitPanel.files": "{count} archivos modificados",
|
||||
"gitPanel.actions": "Acciones Git",
|
||||
"gitPanel.diff": "Diferencias Git",
|
||||
"gitPanel.parentDiff": "Comparado con el primer padre",
|
||||
"gitPanel.backChat": "Volver a la conversación",
|
||||
"instanceShell.rightPanel.tabs.files": "Archivos",
|
||||
"instanceShell.rightPanel.tabs.status": "Estado",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
||||
|
|
@ -161,6 +181,8 @@ export const instanceMessages = {
|
|||
"instanceShell.gitChanges.sections.unstaged": "Cambios",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Agregar al prompt",
|
||||
"instanceShell.gitChanges.actions.stage": "Preparar archivo",
|
||||
"instanceShell.gitChanges.actions.stageSelected": "Preparar {count} archivos seleccionados",
|
||||
"instanceShell.gitChanges.actions.unstageSelected": "Quitar de preparación {count} archivos seleccionados",
|
||||
"instanceShell.gitChanges.actions.unstage": "Quitar del área preparada",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Escribe el mensaje del commit",
|
||||
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||
|
|
|
|||
|
|
@ -89,7 +89,27 @@ export const instanceMessages = {
|
|||
"instanceShell.empty.description": "Envoyez un message ci-dessous pour créer une nouvelle session, ou sélectionnez une session existante pour continuer.",
|
||||
|
||||
"instanceShell.rightPanel.title": "Panneau d'état",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Changements Git",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git",
|
||||
"gitPanel.history": "Historique",
|
||||
"filesPanel.workspace": "Workspace",
|
||||
"filesPanel.commits": "Commits",
|
||||
"filesPanel.viewer": "Aperçu du fichier",
|
||||
"filesPanel.readOnly": "Lecture seule",
|
||||
"filesPanel.binary": "Aperçu indisponible pour ce fichier binaire.",
|
||||
"filesPanel.image.before": "Avant",
|
||||
"filesPanel.image.after": "Après",
|
||||
"gitPanel.changes": "Changements",
|
||||
"gitPanel.view": "Vue Git",
|
||||
"gitPanel.worktree": "Worktree à consulter",
|
||||
"gitPanel.detached": "HEAD détachée",
|
||||
"gitPanel.filter": "Filtrer les commits chargés…",
|
||||
"gitPanel.emptyHistory": "Aucun commit à afficher",
|
||||
"gitPanel.more": "Charger les commits précédents",
|
||||
"gitPanel.files": "{count} fichiers modifiés",
|
||||
"gitPanel.actions": "Actions Git",
|
||||
"gitPanel.diff": "Diff Git",
|
||||
"gitPanel.parentDiff": "Comparaison avec le premier parent",
|
||||
"gitPanel.backChat": "Retour à la conversation",
|
||||
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
||||
"instanceShell.rightPanel.tabs.status": "Statut",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
||||
|
|
@ -161,6 +181,8 @@ export const instanceMessages = {
|
|||
"instanceShell.gitChanges.sections.unstaged": "Changements",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Ajouter au prompt",
|
||||
"instanceShell.gitChanges.actions.stage": "Indexer le fichier",
|
||||
"instanceShell.gitChanges.actions.stageSelected": "Indexer les {count} fichiers sélectionnés",
|
||||
"instanceShell.gitChanges.actions.unstageSelected": "Désindexer les {count} fichiers sélectionnés",
|
||||
"instanceShell.gitChanges.actions.unstage": "Retirer de l'index",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Saisissez le message du commit",
|
||||
"instanceShell.gitChanges.commit.submit": "Valider",
|
||||
|
|
|
|||
|
|
@ -89,7 +89,27 @@ export const instanceMessages = {
|
|||
"instanceShell.empty.description": "שלח הודעה למטה כדי ליצור סשן חדש, או בחר סשן קיים כדי להמשיך.",
|
||||
|
||||
"instanceShell.rightPanel.title": "לוח סטטוס",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "שינויי Git",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git",
|
||||
"gitPanel.history": "היסטוריה",
|
||||
"filesPanel.workspace": "סביבת עבודה",
|
||||
"filesPanel.commits": "קומיטים",
|
||||
"filesPanel.viewer": "תצוגה מקדימה של קובץ",
|
||||
"filesPanel.readOnly": "לקריאה בלבד",
|
||||
"filesPanel.binary": "אין תצוגה מקדימה לקובץ בינארי זה.",
|
||||
"filesPanel.image.before": "לפני",
|
||||
"filesPanel.image.after": "אחרי",
|
||||
"gitPanel.changes": "שינויים",
|
||||
"gitPanel.view": "תצוגת Git",
|
||||
"gitPanel.worktree": "עץ עבודה לעיון",
|
||||
"gitPanel.detached": "HEAD מנותק",
|
||||
"gitPanel.filter": "סינון קומיטים שנטענו…",
|
||||
"gitPanel.emptyHistory": "אין קומיטים להצגה",
|
||||
"gitPanel.more": "טעינת קומיטים קודמים",
|
||||
"gitPanel.files": "{count} קבצים שהשתנו",
|
||||
"gitPanel.actions": "פעולות Git",
|
||||
"gitPanel.diff": "הבדלי Git",
|
||||
"gitPanel.parentDiff": "השוואה להורה הראשון",
|
||||
"gitPanel.backChat": "חזרה לשיחה",
|
||||
"instanceShell.rightPanel.tabs.files": "קבצים",
|
||||
"instanceShell.rightPanel.tabs.status": "סטטוס",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני",
|
||||
|
|
@ -161,6 +181,8 @@ export const instanceMessages = {
|
|||
"instanceShell.gitChanges.sections.unstaged": "שינויים",
|
||||
"instanceShell.gitChanges.actions.insertContext": "הוסף לפרומפט",
|
||||
"instanceShell.gitChanges.actions.stage": "העבר ל-staging",
|
||||
"instanceShell.gitChanges.actions.stageSelected": "העבר {count} קבצים נבחרים ל-staging",
|
||||
"instanceShell.gitChanges.actions.unstageSelected": "הסר {count} קבצים נבחרים מ-staging",
|
||||
"instanceShell.gitChanges.actions.unstage": "הוצא מ-staging",
|
||||
"instanceShell.gitChanges.commit.placeholder": "הזן הודעת commit",
|
||||
"instanceShell.gitChanges.commit.submit": "בצע commit",
|
||||
|
|
|
|||
|
|
@ -89,7 +89,27 @@ export const instanceMessages = {
|
|||
"instanceShell.empty.description": "下にメッセージを送信して新しいセッションを作成するか、既存のセッションを選択して続行してください。",
|
||||
|
||||
"instanceShell.rightPanel.title": "ステータスパネル",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git 変更",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git",
|
||||
"gitPanel.history": "履歴",
|
||||
"filesPanel.workspace": "ワークスペース",
|
||||
"filesPanel.commits": "コミット",
|
||||
"filesPanel.viewer": "ファイルのプレビュー",
|
||||
"filesPanel.readOnly": "読み取り専用",
|
||||
"filesPanel.binary": "このバイナリファイルはプレビューできません。",
|
||||
"filesPanel.image.before": "変更前",
|
||||
"filesPanel.image.after": "変更後",
|
||||
"gitPanel.changes": "変更",
|
||||
"gitPanel.view": "Git 表示",
|
||||
"gitPanel.worktree": "参照するワークツリー",
|
||||
"gitPanel.detached": "切り離された HEAD",
|
||||
"gitPanel.filter": "読み込んだコミットを絞り込む…",
|
||||
"gitPanel.emptyHistory": "表示するコミットはありません",
|
||||
"gitPanel.more": "以前のコミットを読み込む",
|
||||
"gitPanel.files": "変更されたファイル: {count}",
|
||||
"gitPanel.actions": "Git 操作",
|
||||
"gitPanel.diff": "Git 差分",
|
||||
"gitPanel.parentDiff": "最初の親コミットと比較",
|
||||
"gitPanel.backChat": "会話に戻る",
|
||||
"instanceShell.rightPanel.tabs.files": "ファイル",
|
||||
"instanceShell.rightPanel.tabs.status": "ステータス",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
|
||||
|
|
@ -161,6 +181,8 @@ export const instanceMessages = {
|
|||
"instanceShell.gitChanges.sections.unstaged": "変更",
|
||||
"instanceShell.gitChanges.actions.insertContext": "プロンプトに追加",
|
||||
"instanceShell.gitChanges.actions.stage": "ファイルをステージ",
|
||||
"instanceShell.gitChanges.actions.stageSelected": "選択した {count} ファイルをステージ",
|
||||
"instanceShell.gitChanges.actions.unstageSelected": "選択した {count} ファイルのステージを解除",
|
||||
"instanceShell.gitChanges.actions.unstage": "ステージ解除",
|
||||
"instanceShell.gitChanges.commit.placeholder": "コミットメッセージを入力",
|
||||
"instanceShell.gitChanges.commit.submit": "コミット",
|
||||
|
|
|
|||
|
|
@ -89,7 +89,27 @@ export const instanceMessages = {
|
|||
"instanceShell.empty.description": "नयाँ सत्र सिर्जना गर्न तल सन्देश पठाउनुहोस्, वा जारी राख्न अवस्थित सत्र चयन गर्नुहोस्।",
|
||||
|
||||
"instanceShell.rightPanel.title": "स्थिति प्यानल (Status Panel)",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git परिवर्तनहरू",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git",
|
||||
"gitPanel.history": "इतिहास",
|
||||
"filesPanel.workspace": "कार्यक्षेत्र",
|
||||
"filesPanel.commits": "कमिटहरू",
|
||||
"filesPanel.viewer": "फाइल पूर्वावलोकन",
|
||||
"filesPanel.readOnly": "पढ्न मात्र",
|
||||
"filesPanel.binary": "यो बाइनरी फाइलको पूर्वावलोकन उपलब्ध छैन।",
|
||||
"filesPanel.image.before": "अघि",
|
||||
"filesPanel.image.after": "पछि",
|
||||
"gitPanel.changes": "परिवर्तनहरू",
|
||||
"gitPanel.view": "Git दृश्य",
|
||||
"gitPanel.worktree": "हेर्ने कार्यवृक्ष",
|
||||
"gitPanel.detached": "छुट्टिएको HEAD",
|
||||
"gitPanel.filter": "लोड भएका कमिटहरू छान्नुहोस्…",
|
||||
"gitPanel.emptyHistory": "देखाउन कुनै कमिट छैन",
|
||||
"gitPanel.more": "पुराना कमिटहरू लोड गर्नुहोस्",
|
||||
"gitPanel.files": "{count} परिवर्तन भएका फाइलहरू",
|
||||
"gitPanel.actions": "Git कार्यहरू",
|
||||
"gitPanel.diff": "Git भिन्नता",
|
||||
"gitPanel.parentDiff": "पहिलो अभिभावकसँग तुलना",
|
||||
"gitPanel.backChat": "कुराकानीमा फर्कनुहोस्",
|
||||
"instanceShell.rightPanel.tabs.files": "फाइलहरू",
|
||||
"instanceShell.rightPanel.tabs.status": "स्थिति",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "दायाँ प्यानल ट्याबहरू",
|
||||
|
|
@ -161,6 +181,8 @@ export const instanceMessages = {
|
|||
"instanceShell.gitChanges.sections.unstaged": "परिवर्तनहरू",
|
||||
"instanceShell.gitChanges.actions.insertContext": "प्रम्प्टमा थप्नुहोस्",
|
||||
"instanceShell.gitChanges.actions.stage": "फाइल स्टेज गर्नुहोस्",
|
||||
"instanceShell.gitChanges.actions.stageSelected": "चयन गरिएका {count} फाइलहरू स्टेज गर्नुहोस्",
|
||||
"instanceShell.gitChanges.actions.unstageSelected": "चयन गरिएका {count} फाइलहरू अनस्टेज गर्नुहोस्",
|
||||
"instanceShell.gitChanges.actions.unstage": "फाइल अनस्टेज गर्नुहोस्",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Commit सन्देश प्रविष्ट गर्नुहोस्",
|
||||
"instanceShell.gitChanges.commit.submit": "कमिट गर्नुहोस्",
|
||||
|
|
|
|||
|
|
@ -89,7 +89,27 @@ export const instanceMessages = {
|
|||
"instanceShell.empty.description": "Отправьте сообщение ниже, чтобы создать новую сессию, или выберите существующую сессию, чтобы продолжить.",
|
||||
|
||||
"instanceShell.rightPanel.title": "Панель состояния",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Изменения Git",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git",
|
||||
"gitPanel.history": "История",
|
||||
"filesPanel.workspace": "Рабочая область",
|
||||
"filesPanel.commits": "Коммиты",
|
||||
"filesPanel.viewer": "Просмотр файла",
|
||||
"filesPanel.readOnly": "Только чтение",
|
||||
"filesPanel.binary": "Предпросмотр этого двоичного файла недоступен.",
|
||||
"filesPanel.image.before": "До",
|
||||
"filesPanel.image.after": "После",
|
||||
"gitPanel.changes": "Изменения",
|
||||
"gitPanel.view": "Представление Git",
|
||||
"gitPanel.worktree": "Просматриваемое рабочее дерево",
|
||||
"gitPanel.detached": "Отсоединённый HEAD",
|
||||
"gitPanel.filter": "Фильтр загруженных коммитов…",
|
||||
"gitPanel.emptyHistory": "Нет коммитов для отображения",
|
||||
"gitPanel.more": "Загрузить предыдущие коммиты",
|
||||
"gitPanel.files": "Изменённых файлов: {count}",
|
||||
"gitPanel.actions": "Действия Git",
|
||||
"gitPanel.diff": "Различия Git",
|
||||
"gitPanel.parentDiff": "Сравнение с первым родителем",
|
||||
"gitPanel.backChat": "Вернуться к разговору",
|
||||
"instanceShell.rightPanel.tabs.files": "Файлы",
|
||||
"instanceShell.rightPanel.tabs.status": "Статус",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
|
||||
|
|
@ -161,6 +181,8 @@ export const instanceMessages = {
|
|||
"instanceShell.gitChanges.sections.unstaged": "Изменения",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Добавить в промпт",
|
||||
"instanceShell.gitChanges.actions.stage": "Подготовить файл",
|
||||
"instanceShell.gitChanges.actions.stageSelected": "Подготовить выбранные файлы: {count}",
|
||||
"instanceShell.gitChanges.actions.unstageSelected": "Убрать выбранные файлы из индекса: {count}",
|
||||
"instanceShell.gitChanges.actions.unstage": "Убрать из staging",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Введите сообщение коммита",
|
||||
"instanceShell.gitChanges.commit.submit": "Коммит",
|
||||
|
|
|
|||
|
|
@ -78,7 +78,27 @@ export const instanceMessages = {
|
|||
"instanceShell.empty.title": "Seçili session yok",
|
||||
"instanceShell.empty.description": "Yeni bir session oluşturmak için aşağıya bir mesaj gönderin ya da devam etmek için var olan bir session seçin.",
|
||||
"instanceShell.rightPanel.title": "Durum Paneli",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git Değişiklikleri",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git",
|
||||
"gitPanel.history": "Geçmiş",
|
||||
"filesPanel.workspace": "Çalışma alanı",
|
||||
"filesPanel.commits": "Commitler",
|
||||
"filesPanel.viewer": "Dosya önizlemesi",
|
||||
"filesPanel.readOnly": "Salt okunur",
|
||||
"filesPanel.binary": "Bu ikili dosya için önizleme kullanılamıyor.",
|
||||
"filesPanel.image.before": "Önce",
|
||||
"filesPanel.image.after": "Sonra",
|
||||
"gitPanel.changes": "Değişiklikler",
|
||||
"gitPanel.view": "Git görünümü",
|
||||
"gitPanel.worktree": "İncelenecek çalışma ağacı",
|
||||
"gitPanel.detached": "Ayrık HEAD",
|
||||
"gitPanel.filter": "Yüklenen commitleri filtrele…",
|
||||
"gitPanel.emptyHistory": "Gösterilecek commit yok",
|
||||
"gitPanel.more": "Eski commitleri yükle",
|
||||
"gitPanel.files": "{count} değişen dosya",
|
||||
"gitPanel.actions": "Git işlemleri",
|
||||
"gitPanel.diff": "Git farkı",
|
||||
"gitPanel.parentDiff": "İlk üst commit ile karşılaştırma",
|
||||
"gitPanel.backChat": "Sohbete dön",
|
||||
"instanceShell.rightPanel.tabs.files": "Dosyalar",
|
||||
"instanceShell.rightPanel.tabs.status": "Durum",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Sağ panel sekmeleri",
|
||||
|
|
@ -135,6 +155,8 @@ export const instanceMessages = {
|
|||
"instanceShell.gitChanges.sections.unstaged": "Değişiklikler",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Prompt'a ekle",
|
||||
"instanceShell.gitChanges.actions.stage": "Dosyayı stage et",
|
||||
"instanceShell.gitChanges.actions.stageSelected": "Seçili {count} dosyayı stage et",
|
||||
"instanceShell.gitChanges.actions.unstageSelected": "Seçili {count} dosyayı stage’den çıkar",
|
||||
"instanceShell.gitChanges.actions.unstage": "Dosyayı unstage et",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Commit mesajı girin",
|
||||
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||
|
|
|
|||
|
|
@ -89,7 +89,27 @@ export const instanceMessages = {
|
|||
"instanceShell.empty.description": "在下方发送消息以创建新会话,或选择现有会话继续。",
|
||||
|
||||
"instanceShell.rightPanel.title": "状态面板",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git 更改",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git",
|
||||
"gitPanel.history": "历史",
|
||||
"filesPanel.workspace": "工作区",
|
||||
"filesPanel.commits": "提交",
|
||||
"filesPanel.viewer": "文件预览",
|
||||
"filesPanel.readOnly": "只读",
|
||||
"filesPanel.binary": "无法预览此二进制文件。",
|
||||
"filesPanel.image.before": "之前",
|
||||
"filesPanel.image.after": "之后",
|
||||
"gitPanel.changes": "更改",
|
||||
"gitPanel.view": "Git 视图",
|
||||
"gitPanel.worktree": "浏览工作树",
|
||||
"gitPanel.detached": "分离的 HEAD",
|
||||
"gitPanel.filter": "筛选已加载的提交…",
|
||||
"gitPanel.emptyHistory": "没有可显示的提交",
|
||||
"gitPanel.more": "加载更早的提交",
|
||||
"gitPanel.files": "{count} 个已更改文件",
|
||||
"gitPanel.actions": "Git 操作",
|
||||
"gitPanel.diff": "Git 差异",
|
||||
"gitPanel.parentDiff": "与第一个父提交比较",
|
||||
"gitPanel.backChat": "返回对话",
|
||||
"instanceShell.rightPanel.tabs.files": "文件",
|
||||
"instanceShell.rightPanel.tabs.status": "状态",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
|
||||
|
|
@ -161,6 +181,8 @@ export const instanceMessages = {
|
|||
"instanceShell.gitChanges.sections.unstaged": "更改",
|
||||
"instanceShell.gitChanges.actions.insertContext": "添加到提示词",
|
||||
"instanceShell.gitChanges.actions.stage": "暂存文件",
|
||||
"instanceShell.gitChanges.actions.stageSelected": "暂存选中的 {count} 个文件",
|
||||
"instanceShell.gitChanges.actions.unstageSelected": "取消暂存选中的 {count} 个文件",
|
||||
"instanceShell.gitChanges.actions.unstage": "取消暂存",
|
||||
"instanceShell.gitChanges.commit.placeholder": "输入提交信息",
|
||||
"instanceShell.gitChanges.commit.submit": "提交",
|
||||
|
|
|
|||
|
|
@ -1,40 +1,10 @@
|
|||
type RequireFn = (deps: string[], callback: (...args: any[]) => void, errback?: (err: any) => void) => void
|
||||
type MonacoApi = any
|
||||
|
||||
const MONACO_VERSION = "0.52.2"
|
||||
const CDN_VS_ROOT = `https://cdn.jsdelivr.net/npm/monaco-editor@${MONACO_VERSION}/min/vs`
|
||||
const LOCAL_VS_ROOT = "/monaco/vs"
|
||||
|
||||
let monacoPromise: Promise<MonacoApi> | null = null
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = setTimeout(() => reject(new Error("timeout")), ms)
|
||||
promise
|
||||
.then((value) => {
|
||||
clearTimeout(id)
|
||||
resolve(value)
|
||||
})
|
||||
.catch((err) => {
|
||||
clearTimeout(id)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function canReachCdn(): Promise<boolean> {
|
||||
if (typeof fetch === "undefined") return false
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const task = fetch(`${CDN_VS_ROOT}/loader.js`, { method: "HEAD", signal: controller.signal })
|
||||
const response = await withTimeout(task, 1200)
|
||||
controller.abort()
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoaderScript(): Promise<void> {
|
||||
if (typeof document === "undefined") return Promise.resolve()
|
||||
const existing = document.querySelector('script[data-monaco-loader="true"]')
|
||||
|
|
@ -46,7 +16,7 @@ function ensureLoaderScript(): Promise<void> {
|
|||
script.src = `${LOCAL_VS_ROOT}/loader.js`
|
||||
script.async = true
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error("Failed to load Monaco AMD loader"))
|
||||
script.onerror = () => { script.remove(); reject(new Error("Failed to load Monaco AMD loader")) }
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
|
@ -160,29 +130,12 @@ export async function loadMonaco(): Promise<MonacoApi> {
|
|||
configureWorkers()
|
||||
ensureEditorCss()
|
||||
|
||||
const online = await canReachCdn()
|
||||
const requireConfig = getRequireConfig()
|
||||
|
||||
const paths: Record<string, string> = {
|
||||
vs: LOCAL_VS_ROOT,
|
||||
}
|
||||
|
||||
if (online) {
|
||||
paths["vs/basic-languages"] = `${CDN_VS_ROOT}/basic-languages`
|
||||
paths["vs/language"] = `${CDN_VS_ROOT}/language`
|
||||
|
||||
// Baseline languages should remain available offline too.
|
||||
paths["vs/basic-languages/python"] = `${LOCAL_VS_ROOT}/basic-languages/python`
|
||||
paths["vs/basic-languages/markdown"] = `${LOCAL_VS_ROOT}/basic-languages/markdown`
|
||||
paths["vs/basic-languages/cpp"] = `${LOCAL_VS_ROOT}/basic-languages/cpp`
|
||||
paths["vs/basic-languages/kotlin"] = `${LOCAL_VS_ROOT}/basic-languages/kotlin`
|
||||
|
||||
paths["vs/language/typescript"] = `${LOCAL_VS_ROOT}/language/typescript`
|
||||
paths["vs/language/html"] = `${LOCAL_VS_ROOT}/language/html`
|
||||
paths["vs/language/json"] = `${LOCAL_VS_ROOT}/language/json`
|
||||
paths["vs/language/css"] = `${LOCAL_VS_ROOT}/language/css`
|
||||
}
|
||||
|
||||
requireConfig({
|
||||
paths,
|
||||
ignoreDuplicateModules: ["vs/editor/editor.main"],
|
||||
|
|
@ -191,17 +144,11 @@ export async function loadMonaco(): Promise<MonacoApi> {
|
|||
// Load editor core.
|
||||
const [monaco] = await requireAsync(["vs/editor/editor.main"])
|
||||
|
||||
// Load language metadata so we can infer language IDs from paths.
|
||||
// (This is small and should remain local for offline support.)
|
||||
// Note: In Monaco 0.52.x, `vs/basic-languages/monaco.contribution` is bundled
|
||||
// into `vs/editor/editor.main` already. Older builds had additional
|
||||
// `vs/basic-languages/_.contribution` metadata, but that module isn't present
|
||||
// in the current AMD bundle; attempting to load it can trigger a hard
|
||||
// `Unexpected token '<'` if the server falls back to `index.html`.
|
||||
await requireAsync(["vs/basic-languages/monaco.contribution"]).catch(() => [])
|
||||
|
||||
// Language metadata is already part of editor.main in our pinned Monaco.
|
||||
// No extra module round trip is needed before displaying plain text.
|
||||
return (globalThis as any).monaco ?? monaco
|
||||
})()
|
||||
|
||||
return monacoPromise
|
||||
try { return await monacoPromise }
|
||||
catch (error) { monacoPromise = null; throw error }
|
||||
}
|
||||
|
|
|
|||
73
packages/ui/src/lib/monaco/theme.ts
Normal file
73
packages/ui/src/lib/monaco/theme.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { createEffect, onCleanup, type Accessor } from "solid-js"
|
||||
import { useTheme } from "../theme"
|
||||
|
||||
/** Monaco accepts concrete hex colors, rather than CSS variables. Resolve the
|
||||
* shared palette after its DOM update, including custom and Auto palettes. */
|
||||
export function useMonacoTheme(api: Accessor<any>) {
|
||||
const { isDark, colorScheme } = useTheme()
|
||||
createEffect(() => {
|
||||
const monaco = api()
|
||||
if (!monaco) return
|
||||
isDark()
|
||||
colorScheme()
|
||||
const root = document.documentElement
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const context = canvas.getContext("2d")!
|
||||
const probe = document.createElement("span")
|
||||
probe.hidden = true
|
||||
root.append(probe)
|
||||
const color = (token: string, alpha?: string) => {
|
||||
probe.style.color = `var(${token})`
|
||||
context.clearRect(0, 0, 1, 1)
|
||||
context.fillStyle = getComputedStyle(probe).color
|
||||
context.fillRect(0, 0, 1, 1)
|
||||
const bytes = context.getImageData(0, 0, 1, 1).data
|
||||
return `#${Array.from(bytes.slice(0, 3), n => n.toString(16).padStart(2, "0")).join("")}${alpha ?? ""}`
|
||||
}
|
||||
const update = () => {
|
||||
monaco.editor.defineTheme("codenomad", {
|
||||
base: isDark() ? "vs-dark" : "vs", inherit: false,
|
||||
rules: [
|
||||
{ token: "", foreground: color("--text-primary").slice(1) },
|
||||
{ token: "comment", foreground: color("--text-muted").slice(1) },
|
||||
{ token: "keyword", foreground: color("--accent-primary").slice(1) },
|
||||
{ token: "string", foreground: color("--status-success").slice(1) },
|
||||
{ token: "number", foreground: color("--status-warning").slice(1) },
|
||||
{ token: "type", foreground: color("--accent-primary").slice(1) },
|
||||
{ token: "tag", foreground: color("--accent-primary").slice(1) },
|
||||
{ token: "attribute.name", foreground: color("--status-warning").slice(1) },
|
||||
{ token: "delimiter", foreground: color("--text-muted").slice(1) },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": color("--surface-base"), "editor.foreground": color("--text-primary"),
|
||||
"editorGutter.background": color("--surface-base"),
|
||||
"editorLineNumber.foreground": color("--text-muted"), "editorLineNumber.activeForeground": color("--text-primary"),
|
||||
"editor.selectionBackground": color("--accent-primary", "30"),
|
||||
"editor.lineHighlightBackground": color("--accent-primary", "0a"),
|
||||
"editorWidget.background": color("--surface-secondary"), "editorWidget.border": color("--border-base"),
|
||||
"editorBracketHighlight.foreground1": color("--text-primary"),
|
||||
"editorBracketHighlight.foreground2": color("--accent-primary"),
|
||||
"editorBracketHighlight.foreground3": color("--status-success"),
|
||||
"editorBracketHighlight.foreground4": color("--text-primary"),
|
||||
"editorBracketHighlight.foreground5": color("--accent-primary"),
|
||||
"editorBracketHighlight.foreground6": color("--status-success"),
|
||||
"diffEditor.insertedLineBackground": color("--status-success", "14"),
|
||||
"diffEditor.removedLineBackground": color("--status-error", "14"),
|
||||
"diffEditor.insertedTextBackground": color("--status-success", "2b"),
|
||||
"diffEditor.removedTextBackground": color("--status-error", "2b"),
|
||||
"diffEditorGutter.insertedLineBackground": color("--status-success", "24"),
|
||||
"diffEditorGutter.removedLineBackground": color("--status-error", "24"),
|
||||
"diffEditor.diagonalFill": color("--text-muted", "24"),
|
||||
"diffEditor.unchangedRegionBackground": color("--surface-secondary"),
|
||||
"diffEditor.unchangedRegionForeground": color("--text-muted"),
|
||||
},
|
||||
})
|
||||
monaco.editor.setTheme("codenomad")
|
||||
}
|
||||
update()
|
||||
const observer = new MutationObserver(update)
|
||||
observer.observe(root, { attributes: true, attributeFilter: ["style", "class", "data-color-scheme"] })
|
||||
onCleanup(() => { observer.disconnect(); probe.remove() })
|
||||
})
|
||||
}
|
||||
35
packages/ui/src/stores/files-preview.ts
Normal file
35
packages/ui/src/stores/files-preview.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { createSignal } from "solid-js"
|
||||
|
||||
export interface FilePreviewTarget {
|
||||
kind?: "workspace" | "diff"
|
||||
sessionId: string
|
||||
slug: string
|
||||
directory: string
|
||||
serviceDirectory?: string
|
||||
path: string
|
||||
originalPath?: string | null
|
||||
scope?: "staged" | "unstaged"
|
||||
commit?: string
|
||||
subject?: string
|
||||
}
|
||||
|
||||
// Only the current selection per project; file bytes belong to the mounted reader.
|
||||
const [targets, setTargets] = createSignal(new Map<string, FilePreviewTarget>())
|
||||
export const getFilePreview = (instanceId: string) => targets().get(instanceId) ?? null
|
||||
export function openFilePreview(instanceId: string, target: FilePreviewTarget) {
|
||||
setTargets(previous => {
|
||||
const next = new Map(previous)
|
||||
next.delete(instanceId)
|
||||
next.set(instanceId, target)
|
||||
if (next.size > 32) next.delete(next.keys().next().value!)
|
||||
return next
|
||||
})
|
||||
}
|
||||
export function closeFilePreview(instanceId: string) {
|
||||
setTargets(previous => {
|
||||
if (!previous.has(instanceId)) return previous
|
||||
const next = new Map(previous)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
|
@ -10,3 +10,6 @@
|
|||
@import "./components/transcript-filters.css";
|
||||
@import "./panels/right-panel.css";
|
||||
@import "./panels/control-hover.css";
|
||||
@import "./panels/git-history.css";
|
||||
@import "./panels/workspace-tree.css";
|
||||
@import "./panels/file-row-actions.css";
|
||||
|
|
|
|||
|
|
@ -15,3 +15,15 @@
|
|||
background-color: var(--surface-secondary);
|
||||
background-image: linear-gradient(var(--surface-hover-overlay), var(--surface-hover-overlay));
|
||||
}
|
||||
|
||||
/* Keep selected backgrounds intact and share the same subtle local rollover
|
||||
across Files modes and their inline/overflow controls. */
|
||||
.workspace-tree-row:hover,
|
||||
.workspace-tree-row:focus-within,
|
||||
.git-panel-file-row:hover,
|
||||
.git-panel-file-row:focus-within,
|
||||
.file-row-icon-button:hover:not(:disabled),
|
||||
.file-row-icon-button:focus-visible:not(:disabled),
|
||||
.git-panel button:not(.git-panel-file-main):hover:not(:disabled) {
|
||||
background-image: linear-gradient(var(--surface-hover-overlay), var(--surface-hover-overlay));
|
||||
}
|
||||
|
|
|
|||
86
packages/ui/src/styles/panels/file-row-actions.css
Normal file
86
packages/ui/src/styles/panels/file-row-actions.css
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
.file-row-actions {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.file-row-inline-actions,
|
||||
.file-row-pinned-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.file-row-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-row-icon-button:hover:not(:disabled),
|
||||
.file-row-icon-button:focus-visible:not(:disabled) {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--text-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.file-row-icon-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.file-row-icon-button:focus-visible {
|
||||
outline: 1px solid var(--accent-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.file-row-actions > .action-overflow-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-row-actions[data-compact="true"] > .action-overflow-trigger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.file-row-actions[data-compact="true"] .file-row-inline-actions {
|
||||
position: absolute;
|
||||
inset-inline-end: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.workspace-tree-row .file-row-actions,
|
||||
.git-panel-file-row .file-row-actions {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.workspace-tree-row:hover .file-row-actions,
|
||||
.workspace-tree-row:focus-within .file-row-actions,
|
||||
.git-panel-file-row:hover .file-row-actions,
|
||||
.git-panel-file-row:focus-within .file-row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.file-row-actions:has([aria-pressed="true"]),
|
||||
.file-row-actions:has([data-expanded]) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.workspace-tree-row .file-row-actions,
|
||||
.git-panel-file-row .file-row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
75
packages/ui/src/styles/panels/git-history.css
Normal file
75
packages/ui/src/styles/panels/git-history.css
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
.git-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.git-panel-context { padding: var(--space-md); border-bottom: 1px solid var(--border-base); }
|
||||
.git-panel-branch { display: flex; align-items: center; gap: var(--space-sm); }
|
||||
.git-panel-branch strong { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.git-panel-worktree {
|
||||
width: 100%; margin-top: var(--space-sm); padding: var(--space-xs);
|
||||
background: var(--surface-base); color: var(--text-muted); border: 1px solid var(--border-base);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
.git-panel-tools { padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--border-base); }
|
||||
.git-panel-switch { display: flex; width: fit-content; border: 1px solid var(--border-base); }
|
||||
.git-panel-switch button { padding: var(--space-xs) var(--space-sm); color: var(--text-muted); font-size: var(--font-size-xs); }
|
||||
.git-panel-switch button + button { border-inline-start: 1px solid var(--border-base); }
|
||||
.git-panel-count { display: block; margin-top: var(--space-sm); font-size: var(--font-size-xs); color: var(--text-muted); opacity: 0.65; }
|
||||
.git-panel-switch button[aria-pressed="true"] { background: var(--selection-neutral-strong-bg); color: var(--text-primary); }
|
||||
.git-panel-switch .badge-shape { padding: 0 var(--space-xs); font-size: var(--font-size-xs); }
|
||||
.git-panel-body { min-height: 0; overflow: auto; flex: 1; }
|
||||
.git-panel-filter {
|
||||
display: block; width: calc(100% - 2 * var(--space-md)); margin: var(--space-sm) var(--space-md);
|
||||
padding: var(--space-xs) var(--space-sm); color: var(--text-primary); background: var(--surface-base);
|
||||
border: 1px solid var(--border-base); font-size: var(--font-size-xs);
|
||||
}
|
||||
.git-history-row {
|
||||
display: flex; width: 100%; text-align: start; gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
.git-history-mark { flex-shrink: 0; margin-top: 2px; color: var(--text-muted); }
|
||||
.git-history-row-content { display: flex; flex-direction: column; gap: var(--space-xs); min-width: 0; flex: 1; }
|
||||
.git-history-row-content strong { font-weight: var(--font-weight-medium); overflow-wrap: anywhere; }
|
||||
.git-history-meta { display: flex; gap: var(--space-sm); justify-content: space-between; font-size: var(--font-size-xs); color: var(--text-muted); }
|
||||
.git-history-meta > span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.git-history-refs { font-size: var(--font-size-xs); color: var(--accent-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.git-panel-back { display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-sm) var(--space-md); width: 100%; border-bottom: 1px solid var(--border-base); }
|
||||
.git-commit-summary { padding: var(--space-md); border-bottom: 1px solid var(--border-base); }
|
||||
.git-commit-summary code, .git-commit-summary > span { font-size: var(--font-size-xs); color: var(--text-muted); }
|
||||
.git-commit-summary p { white-space: pre-wrap; overflow-wrap: anywhere; margin: var(--space-sm) 0; }
|
||||
.git-panel-file { display: flex; width: 100%; align-items: center; gap: var(--space-sm); padding: var(--space-sm) var(--space-md); text-align: start; }
|
||||
.git-panel-file > span:nth-child(2) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--font-size-xs); }
|
||||
.git-panel-file small { flex-shrink: 0; font-size: var(--font-size-xs); opacity: 0.65; }
|
||||
.git-panel-file-selected { background: var(--selection-neutral-bg); }
|
||||
.git-panel-file-row { display: flex; align-items: center; }
|
||||
.git-panel-file-main { flex: 1; min-width: 0; width: auto; background: transparent; }
|
||||
.git-panel .git-change-section-count { opacity: 0.65; }
|
||||
.git-panel-file-row .file-list-item-deletions { color: var(--status-error); }
|
||||
.git-panel-file-row .file-list-item-additions { color: var(--status-success); }
|
||||
.git-panel-file-stage { flex-shrink: 0; opacity: 0; margin-inline-end: var(--space-sm); background: transparent; }
|
||||
.git-panel-file-stage:hover:not(:disabled) { background: transparent; box-shadow: none; color: var(--text-primary); opacity: 1; }
|
||||
.git-panel-file-row:hover .git-panel-file-stage,
|
||||
.git-panel-file-row:focus-within .git-panel-file-stage { opacity: 1; }
|
||||
.git-drop-zone { min-height: var(--space-md); }
|
||||
.git-drop-active { box-shadow: inset 0 0 0 1px var(--accent-primary); }
|
||||
.git-file-status { width: 14px; flex-shrink: 0; font-family: monospace; color: var(--text-muted); font-size: var(--font-size-xs); }
|
||||
.git-file-status-A { color: var(--status-success); }
|
||||
.git-file-status-D { color: var(--status-error); }
|
||||
.git-panel-more { display: block; width: 100%; padding: var(--space-sm); color: var(--text-secondary); font-size: var(--font-size-xs); }
|
||||
.git-panel button:disabled { opacity: 0.5; }
|
||||
.git-diff-view { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; overflow: hidden; background: var(--surface-base); color: var(--text-primary); }
|
||||
.git-diff-header { padding: var(--space-sm); flex-wrap: wrap; }
|
||||
.git-diff-heading { display: flex; flex-direction: column; flex: 1; min-width: 100px; font-size: var(--font-size-sm); gap: var(--space-xs); }
|
||||
.git-diff-heading > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.git-diff-heading > span { color: var(--text-muted); font-size: var(--font-size-xs); }
|
||||
.git-diff-content { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.git-image-diff { flex: 1; min-height: 0; overflow: auto; display: grid; gap: var(--space-md); padding: var(--space-md); align-content: start; }
|
||||
.git-image-diff[data-view="split"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.git-image-version { min-width: 0; margin: 0; }
|
||||
.git-image-version figcaption { color: var(--text-muted); font-size: var(--font-size-xs); margin-bottom: var(--space-sm); }
|
||||
.git-image-version img { display: block; max-width: 100%; height: auto; }
|
||||
20
packages/ui/src/styles/panels/workspace-tree.css
Normal file
20
packages/ui/src/styles/panels/workspace-tree.css
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
.workspace-tree { padding-block: var(--space-sm); font-size: var(--font-size-sm); }
|
||||
.workspace-tree-root { display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-sm) var(--space-md); }
|
||||
.workspace-tree-root strong { font-weight: var(--font-weight-medium); overflow: hidden; text-overflow: ellipsis; }
|
||||
.workspace-tree-row {
|
||||
display: flex; align-items: center; gap: 5px; width: 100%; min-height: 27px;
|
||||
padding-block: 3px; padding-inline-end: var(--space-sm); text-align: start;
|
||||
color: var(--text-secondary); font-size: var(--font-size-xs);
|
||||
}
|
||||
.workspace-tree-row > svg { flex-shrink: 0; color: var(--text-muted); }
|
||||
.workspace-tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-tree-label { flex: 1; min-width: 0; }
|
||||
.workspace-tree-row[aria-selected="true"] { background: var(--selection-neutral-strong-bg); color: var(--text-primary); }
|
||||
.workspace-tree-row:focus-visible { outline: 1px solid var(--accent-primary); outline-offset: -1px; }
|
||||
.workspace-tree-spacer { width: 12px; flex-shrink: 0; }
|
||||
.workspace-image { flex: 1; min-height: 0; overflow: auto; display: grid; place-items: center; padding: var(--space-lg); }
|
||||
.workspace-image img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.workspace-markdown { flex: 1; min-height: 0; overflow: auto; padding: var(--space-lg); }
|
||||
.git-panel-switch { max-width: 100%; }
|
||||
.git-panel-switch button { white-space: nowrap; }
|
||||
.git-panel-tools { overflow-x: auto; flex-shrink: 0; }
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { fileURLToPath } from "node:url"
|
||||
import { copyMonacoPublicAssets } from "../../../scripts/monaco-public-assets.js"
|
||||
|
||||
export function prepareGitPrototypeAssets() {
|
||||
const source = fileURLToPath(new URL("../../../../../node_modules/monaco-editor/min/vs", import.meta.url))
|
||||
const renderer = fileURLToPath(new URL("../../../src/renderer", import.meta.url))
|
||||
copyMonacoPublicAssets({ uiRendererRoot: renderer, sourceRoots: [source] })
|
||||
}
|
||||
177
packages/ui/tests/browser/fixtures/git-history.tsx
Normal file
177
packages/ui/tests/browser/fixtures/git-history.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { Show, Suspense, createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { I18nProvider, useI18n } from "../../../src/lib/i18n"
|
||||
import { ThemeProvider } from "../../../src/lib/theme"
|
||||
import { ConfigProvider, updatePreferences, setThemePreference } from "../../../src/stores/preferences"
|
||||
import { addInstance } from "../../../src/stores/instances"
|
||||
import { sdkManager } from "../../../src/lib/sdk-manager"
|
||||
import { serverApi } from "../../../src/lib/api-client"
|
||||
import { ensureWorktreesLoaded } from "../../../src/stores/worktrees"
|
||||
import { createCoreRightPanelRuntime } from "../../../src/components/instance/shell/right-panel/core-runtime"
|
||||
import { FilesPreviewView } from "../../../src/components/files-preview-view"
|
||||
import AlertDialog from "../../../src/components/alert-dialog"
|
||||
import { closeFilePreview, getFilePreview } from "../../../src/stores/files-preview"
|
||||
import { parseRightPanelCustomization } from "../../../src/components/instance/shell/right-panel/registry"
|
||||
import { sessions, setActiveSession, setProviders } from "../../../src/stores/session-state"
|
||||
import { fetchSessions } from "../../../src/stores/session-api"
|
||||
import { invalidateFilesystemCaches } from "../../../src/lib/filesystem-events"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const id = "git-prototype", sessionId = "session"
|
||||
const imagesFixture = new URLSearchParams(location.search).has("images")
|
||||
const imageBytes = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a6xUAAAAASUVORK5CYII="
|
||||
const SessionView = new URLSearchParams(location.search).has("session")
|
||||
? (await import("../../../src/components/session/session-view")).default : undefined
|
||||
const [selectedSession, setSelectedSession] = createSignal(sessionId)
|
||||
const calls: Array<{ kind: string; slug?: string; path?: string; message?: string }> = []
|
||||
const savedFiles = new Map<string, string>()
|
||||
serverApi.writeWorkspaceFile = async (_id, path, contents, options) => {
|
||||
calls.push({ kind: "save", path, slug: options?.directory, message: contents })
|
||||
savedFiles.set(JSON.stringify([options?.directory, path]), contents)
|
||||
}
|
||||
const entries = [
|
||||
{ slug: "root", label: "CodeNomad", directory: "/CodeNomad", branch: "feat/git-history", kind: "root" as const },
|
||||
{ slug: "review", label: "review", directory: "/CodeNomad/.codenomad/worktrees/review", branch: "dev", kind: "worktree" as const },
|
||||
]
|
||||
const commits = [
|
||||
["Make Git history the starting point", "HEAD -> feat/git-history"],
|
||||
["Open file diffs beside the Git navigator", "origin/feat/git-history"],
|
||||
["Preserve conversation drafts when opening a preview", ""],
|
||||
["Merge pull request #788: qualify OpenCode 2.0.18", "dev"],
|
||||
].map(([subject, refs], i) => ({ id: String(i + 1).repeat(40), subject, refs, author: "Pascal André",
|
||||
date: `2026-09-${26 - i}T12:00:00Z`, parents: [String(i + 2).repeat(40)] }))
|
||||
const before = 'export function openGitPanel() {\n return {\n title: "Git Changes",\n view: "changes",\n diffPlacement: "sidebar",\n }\n}\n'
|
||||
const after = 'export function openGitPanel() {\n return {\n title: "Git",\n view: "history",\n diffPlacement: "conversation",\n preserveDraft: true,\n }\n}\n'
|
||||
const directoryFiles: Record<string, string[]> = {
|
||||
".": [".github/", "dev-docs/", "src/", "README.md", "package.json", ...(imagesFixture ? ["image.png"] : [])],
|
||||
".github": ["workflows/"],
|
||||
".github/workflows": ["build-and-upload.yml", "pr-build.yml", "release.yml", "update-winget.yml"],
|
||||
"dev-docs": ["ui-harmonization-demo/", "architecture.md", "BROWSER_AUTOMATION.md"],
|
||||
"dev-docs/ui-harmonization-demo": ["palette.svg", "design-notes.md"],
|
||||
"src": ["components/", "styles/", "index.ts"],
|
||||
"src/components": ["git-panel.tsx", "workspace-tree.tsx"],
|
||||
"src/styles": ["panels/"],
|
||||
"src/styles/panels": ["git-history.css"],
|
||||
}
|
||||
serverApi.listWorkspaceFiles = async (_id, path = ".", directory) => {
|
||||
calls.push({ kind: "files", path, slug: directory })
|
||||
return (directoryFiles[path] ?? []).map(name => ({ name: name.replace(/\/$/, ""),
|
||||
path: `${path === "." ? "" : path + "/"}${name.replace(/\/$/, "")}`, type: name.endsWith("/") ? "directory" as const : "file" as const }))
|
||||
}
|
||||
serverApi.previewWorkspaceFile = async (_id, path, directory) => {
|
||||
calls.push({ kind: "file", path, slug: directory })
|
||||
if (path.endsWith(".png")) return { workspaceId: _id, relativePath: path, encoding: "base64", contents: imageBytes }
|
||||
const text = savedFiles.get(JSON.stringify([directory, path])) ?? (path.endsWith(".svg")
|
||||
? '<svg xmlns="http://www.w3.org/2000/svg" width="720" height="420"><rect width="720" height="420" fill="#202731"/><text x="40" y="65" fill="#f2e8cf" font-size="28" font-family="sans-serif">CodeNomad / Palette study</text><rect x="40" y="110" width="150" height="240" fill="#a6b8a0"/><rect x="205" y="110" width="150" height="240" fill="#c6b3a0"/><rect x="370" y="110" width="150" height="240" fill="#a1b7c5"/><rect x="535" y="110" width="145" height="240" fill="#d5c8ab"/></svg>'
|
||||
: path.endsWith(".md") ? '# CodeNomad\n\nUn espace pour explorer les fichiers, les changements et les commits.\n\n## Trois points de vue\n\n- **Workspace** : les fichiers du projet\n- **Changes** : les modifications locales\n- **Commits** : les versions enregistrées\n\n```ts\nconst viewer = "conversation"\n```\n'
|
||||
: path.endsWith(".yml") ? 'name: Update Winget\n\non:\n workflow_call:\n inputs:\n release_tag:\n description: "Stable release tag to inspect"\n required: true\n type: string\n\njobs:\n update-manifest:\n runs-on: windows-latest\n steps:\n - uses: actions/checkout@v4\n'
|
||||
: path.endsWith(".json") ? '{\n "name": "codenomad",\n "private": true\n}\n' : after)
|
||||
return { workspaceId: _id, relativePath: path, encoding: "base64", contents: btoa(String.fromCharCode(...new TextEncoder().encode(text))) }
|
||||
}
|
||||
const model = { providerID: "fixture", id: "fixture" }
|
||||
const sessionInfo = (sessionID: string) => ({ id: sessionID, title: sessionID, agent: "build", model, projectID: "fixture", location: { directory: "/CodeNomad" },
|
||||
cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 } })
|
||||
const client: any = { vcs: { status: async () => ({ data: [] }) },
|
||||
session: {
|
||||
active: async () => ({}), inbox: { list: async () => ({ data: [] }) },
|
||||
list: async () => ({ data: [sessionInfo(sessionId), sessionInfo("other")], cursor: {} }),
|
||||
get: async ({ sessionID }: any) => sessionInfo(sessionID),
|
||||
instructions: { entry: { remove: async () => {}, put: async () => {} } },
|
||||
}, model: { default: async () => model }, message: { list: async () => ({ data: [], cursor: {} }) },
|
||||
}
|
||||
;(sdkManager as any).clients.set(`${id}:/workspaces/${id}/instance`, client)
|
||||
serverApi.fetchConfigOwner = async () => ({ settings: { locale: "fr" } }) as any
|
||||
serverApi.patchConfigOwner = async (_owner, patch) => patch as any
|
||||
serverApi.fetchStateOwner = async () => ({}) as any
|
||||
serverApi.patchStateOwner = async (_owner, patch) => patch as any
|
||||
serverApi.fetchWorktrees = async () => ({ isGitRepo: true, defaultDirectory: "/CodeNomad/.codenomad/worktrees", worktrees: entries })
|
||||
serverApi.fetchGitHistory = async (_id, slug) => {
|
||||
calls.push({ kind: "history", slug })
|
||||
return { branch: entries.find(entry => entry.slug === slug)!.branch, head: commits[0].id, commits: slug === "root" ? commits : commits.slice(3), hasMore: false }
|
||||
}
|
||||
serverApi.fetchGitCommit = async (_id, slug, commit) => {
|
||||
calls.push({ kind: "commit", slug })
|
||||
return { id: commit, parent: commits[1].id, message: commits.find(entry => entry.id === commit)!.subject + "\n\nKeep history and local changes as two simple paths to the central diff.",
|
||||
files: [{ path: "src/components/git-panel.tsx", originalPath: null, status: "M" }, { path: "src/styles/panels/git-history.css", originalPath: null, status: "A" }, ...(imagesFixture ? [{ path: "image.png", originalPath: null, status: "M" }] : [])] }
|
||||
}
|
||||
serverApi.fetchGitCommitDiff = async (_id, slug, _commit, path) => { calls.push({ kind: "commit-diff", slug, path }); return path.endsWith(".png")
|
||||
? { path, before: "", after: "", isBinary: true, image: { mime: "image/png", before: imageBytes, after: imageBytes } } : { path, before, after, isBinary: false } }
|
||||
serverApi.fetchWorktreeGitStatus = async (_id, slug) => {
|
||||
calls.push({ kind: "status", slug })
|
||||
return [{ path: "src/components/git-panel.tsx", originalPath: null, stagedStatus: "modified", stagedAdditions: 4, stagedDeletions: 2, unstagedStatus: null, unstagedAdditions: 0, unstagedDeletions: 0 },
|
||||
{ path: "src/styles/panels/git-history.css", originalPath: null, stagedStatus: null, stagedAdditions: 0, stagedDeletions: 0, unstagedStatus: "untracked", unstagedAdditions: 34, unstagedDeletions: 0 },
|
||||
...(imagesFixture ? [{ path: "image.png", originalPath: null, stagedStatus: null, stagedAdditions: 0, stagedDeletions: 0, unstagedStatus: "modified", unstagedAdditions: 0, unstagedDeletions: 0 }] : [])]
|
||||
}
|
||||
serverApi.fetchWorktreeGitDiff = async (_id, slug, input) => { calls.push({ kind: "local-diff", slug, path: input.path }); return input.path.endsWith(".png")
|
||||
? { path: input.path, scope: input.scope, before: "", after: "", isBinary: true, image: { mime: "image/png", before: imageBytes, after: imageBytes } }
|
||||
: { path: input.path, scope: input.scope, before, after, isBinary: false } }
|
||||
// Record explicit mutations without touching a real repository.
|
||||
serverApi.stageWorktreeGitPaths = async (_id, slug, input) => {
|
||||
calls.push({ kind: "stage", slug, path: input.paths.join(",") }); return { ok: true }
|
||||
}
|
||||
serverApi.unstageWorktreeGitPaths = async (_id, slug, input) => {
|
||||
calls.push({ kind: "unstage", slug, path: input.paths.join(",") }); return { ok: true }
|
||||
}
|
||||
serverApi.commitWorktreeGitChanges = async (_id, slug, input) => {
|
||||
calls.push({ kind: "submit", slug, message: input.message }); return { ok: true }
|
||||
}
|
||||
const instance = { id, folder: "/CodeNomad", projectName: "CodeNomad", port: 0, pid: 0, status: "ready" as const, client, proxyPath: `/workspaces/${id}/instance` }
|
||||
addInstance(instance)
|
||||
await ensureWorktreesLoaded(id)
|
||||
if (SessionView) {
|
||||
setProviders(previous => new Map(previous).set(id, [{ id: "fixture", name: "Fixture", models: [{ id: "fixture", name: "Fixture", providerId: "fixture", limit: { context: 10000, output: 1000 }, cost: { input: 0, output: 0 } }] }]))
|
||||
await fetchSessions(id)
|
||||
setActiveSession(id, sessionId)
|
||||
}
|
||||
|
||||
function Prototype() {
|
||||
const { t } = useI18n()
|
||||
const [active, setActive] = createSignal(true)
|
||||
const runtime = createCoreRightPanelRuntime({ t, instanceId: id, instance, isActive: active,
|
||||
activeSessionId: selectedSession, activeSession: () => sessions().get(id)?.get(selectedSession()) ?? null, isPhoneLayout: () => false,
|
||||
rightDrawerWidth: () => 360, rightDrawerWidthInitialized: () => true, promptInputApi: () => null,
|
||||
rightPanelTab: () => "files", expandedItems: () => [], onExpandedItemsChange() {},
|
||||
customization: () => parseRightPanelCustomization(null), onCustomizationChange() {}, extraStatusSections: () => [],
|
||||
})
|
||||
const tab = runtime.create({ instanceId: id, t, activeSessionId: () => sessionId, isTabActive: () => true, openTab() {} }).tabs!.find(tab => tab.id === "files")!
|
||||
;(window as any).fixture = { calls, setActive, close: () => closeFilePreview(id), target: () => getFilePreview(id),
|
||||
invalidate: () => invalidateFilesystemCaches(id),
|
||||
switchSession: (value: string) => { setSelectedSession(value); if (SessionView) setActiveSession(id, value) },
|
||||
holdFile: () => {
|
||||
const read = serverApi.previewWorkspaceFile
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
serverApi.previewWorkspaceFile = async (...args) => { const data = await read(...args); await gate; return data }
|
||||
;(window as any).fixture.releaseFile = () => { release(); serverApi.previewWorkspaceFile = read }
|
||||
},
|
||||
holdCommit: () => {
|
||||
const read = serverApi.fetchGitCommit
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
serverApi.fetchGitCommit = async (...args) => { const data = await read(...args); await gate; return data }
|
||||
;(window as any).fixture.releaseCommit = release
|
||||
} }
|
||||
return <div style={{ height: "100vh", display: "flex", "flex-direction": "column", background: "var(--surface-base)", color: "var(--text-primary)" }}>
|
||||
<AlertDialog />
|
||||
<header class="window-header" style={{ padding: "12px 18px" }}><strong>CodeNomad</strong><span class="text-xs text-muted">Prototype Files · données de démonstration</span></header>
|
||||
<div style={{ display: "flex", flex: 1, "min-height": "0" }}>
|
||||
<main style={{ display: "flex", "flex-direction": "column", flex: 1, "min-width": "0" }}>
|
||||
<Show when={!SessionView} fallback={SessionView ? <SessionView sessionId={selectedSession()} activeSessions={sessions().get(id)!} instanceId={id} instanceFolder="/CodeNomad" escapeInDebounce={false} isActive={active()} /> : undefined}>
|
||||
<Show when={getFilePreview(id)} fallback={<div style={{ flex: 1, padding: "32px", overflow: "auto" }}>
|
||||
<div class="text-xs text-muted">CONVERSATION</div>
|
||||
<h2 style={{ "font-size": "22px", margin: "12px 0 32px" }}>Explorer le travail en cours</h2>
|
||||
<div style={{ background: "var(--surface-secondary)", padding: "20px", "max-width": "650px" }}>Le panneau Files propose trois points de vue : Workspace, Changes et Commits. Choisissez un fichier pour afficher son contenu ou son diff ici.</div>
|
||||
</div>}>{target => <FilesPreviewView instanceId={id} target={target()} active={active()} onClose={() => closeFilePreview(id)} />}</Show>
|
||||
<textarea aria-label="Brouillon" placeholder="Continuer avec l’agent…" style={{ height: "100px", resize: "none", margin: "12px", padding: "14px", border: "1px solid var(--border-base)", background: "var(--surface-base)", color: "var(--text-primary)" }} />
|
||||
</Show>
|
||||
</main>
|
||||
<aside style={{ width: "360px", "min-width": "260px", "max-width": "48vw", "border-left": "1px solid var(--border-base)", display: "flex", "flex-direction": "column" }}>
|
||||
<div class="window-header" style={{ padding: "10px 14px" }}><strong>{t("instanceShell.rightPanel.tabs.files")}</strong></div>
|
||||
<div style={{ flex: 1, "min-height": "0" }}><Suspense>{tab.render()}</Suspense></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
render(() => <ConfigProvider><ThemeProvider><I18nProvider><Prototype /></I18nProvider></ThemeProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
await updatePreferences({ locale: "fr" })
|
||||
await setThemePreference("dark")
|
||||
685
packages/ui/tests/browser/git-history.test.ts
Normal file
685
packages/ui/tests/browser/git-history.test.ts
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { chromium, type Browser } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { prepareGitPrototypeAssets } from "./fixtures/git-history-assets.mjs"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
test("large Changes inventories keep the reader controls responsive", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 2000, height: 1120 } })
|
||||
try {
|
||||
await page.goto(url)
|
||||
await page.getByRole("treeitem", { name: "package.json", exact: true }).waitFor()
|
||||
// Prepare the local Monaco chunk before timing inventory interactions;
|
||||
// Vite's first compilation is not the packaged renderer's input latency.
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · package.json", exact: true }).click()
|
||||
await page.locator('.workspace-file-view .view-line').first().waitFor()
|
||||
await page.getByRole("button", { name: "Retour à la conversation", exact: true }).click()
|
||||
await page.evaluate(async () => {
|
||||
const apiPath = "/src/lib/api-client.ts"
|
||||
const { serverApi } = await import(apiPath)
|
||||
serverApi.fetchWorktreeGitStatus = async () => Array.from({ length: 1600 }, (_, index) => ({
|
||||
path: `packages/electron-app/electron/main/file-${index}.test.ts`, originalPath: null,
|
||||
stagedStatus: null, stagedAdditions: 0, stagedDeletions: 0,
|
||||
unstagedStatus: "deleted", unstagedAdditions: 0, unstagedDeletions: 350,
|
||||
}))
|
||||
;(window as any).fixture.invalidate()
|
||||
})
|
||||
await page.getByRole("button", { name: "Changements", exact: true }).click()
|
||||
await page.locator('.git-panel-file-row').nth(1599).waitFor({ state: "attached", timeout: 15000 })
|
||||
await page.getByRole("button", { name: "Workspace", exact: true }).click({ timeout: 5000 })
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · package.json", exact: true }).click({ timeout: 5000 })
|
||||
await page.locator('.workspace-file-view .view-line').first().waitFor({ timeout: 5000 })
|
||||
} finally { await page.close() }
|
||||
})
|
||||
test("Workspace editing saves the exact directory, retains drafts and checks external changes", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
const edit = async (text: string) => page.evaluate(text => {
|
||||
const editor = (window as any).monaco.editor.getEditors().find((editor: any) => editor.getModel()?.uri.toString().includes("workspace-editor"))
|
||||
editor.getModel().setValue(text)
|
||||
editor.focus()
|
||||
}, text)
|
||||
try {
|
||||
await page.goto(url)
|
||||
const eye = () => page.getByRole("button", { name: "Aperçu du fichier · package.json", exact: true })
|
||||
const save = () => page.getByRole("button", { name: "Enregistrer (Ctrl+S)", exact: true })
|
||||
await eye().click()
|
||||
await page.locator('.workspace-file-view .view-line').first().waitFor()
|
||||
await edit('{"edited": "écriture"}')
|
||||
await page.evaluate(() => (window as any).fixture.invalidate())
|
||||
await eye().click()
|
||||
await eye().click()
|
||||
await page.locator('.workspace-file-view .view-line').filter({ hasText: 'écriture' }).waitFor()
|
||||
await save().click()
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.some((call: any) => call.kind === "save" && call.message.includes("écriture") && call.slug === "/CodeNomad"))
|
||||
await page.waitForFunction(() => document.querySelector<HTMLButtonElement>('[aria-label="Enregistrer (Ctrl+S)"]')?.disabled)
|
||||
await edit('{"local": true}')
|
||||
await page.evaluate(async () => {
|
||||
const apiPath = "/src/lib/api-client.ts"
|
||||
const { serverApi } = await import(apiPath)
|
||||
await serverApi.writeWorkspaceFile("git-prototype", "package.json", '{"agent": true}', { directory: "/CodeNomad" })
|
||||
})
|
||||
await save().click()
|
||||
await page.getByRole("button", { name: "Annuler", exact: true }).click()
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.calls.filter((call: any) => call.kind === "save").length), 2)
|
||||
await save().click()
|
||||
await page.getByRole("button", { name: "Écraser", exact: true }).click()
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.filter((call: any) => call.kind === "save").length === 3)
|
||||
await edit('{"keyboard": true}')
|
||||
await page.keyboard.press("Control+s")
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.some((call: any) => call.kind === "save" && call.message.includes("keyboard")))
|
||||
await edit('{"discard": true}')
|
||||
await page.locator('.workspace-file-view').getByRole("button", { name: "Actualiser", exact: true }).click()
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Actualiser", exact: true }).click()
|
||||
await page.locator('.workspace-file-view .view-line').filter({ hasText: 'keyboard' }).waitFor()
|
||||
assert.equal(await save().isDisabled(), true)
|
||||
} finally { await page.close() }
|
||||
})
|
||||
test("a reader reopened before save completion retains its undo through filesystem invalidation", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
try {
|
||||
await page.goto(url)
|
||||
const eye = () => page.getByRole("button", { name: "Aperçu du fichier · package.json", exact: true })
|
||||
await eye().click()
|
||||
await page.locator('.workspace-file-view .view-line').first().waitFor()
|
||||
await page.evaluate(async () => {
|
||||
const apiPath = "/src/lib/api-client.ts"
|
||||
const { serverApi } = await import(apiPath)
|
||||
const write = serverApi.writeWorkspaceFile
|
||||
serverApi.writeWorkspaceFile = async (...args: Parameters<typeof write>) => {
|
||||
;(window as any).writeStarted = true
|
||||
await new Promise<void>(resolve => { (window as any).releaseWrite = resolve })
|
||||
await write(...args)
|
||||
serverApi.writeWorkspaceFile = write
|
||||
}
|
||||
const editor = (window as any).monaco.editor.getEditors().find((e: any) => e.getModel()?.uri.toString().includes("workspace-editor"))
|
||||
;(window as any).originalText = editor.getValue()
|
||||
editor.setValue('{"saved": true}')
|
||||
})
|
||||
await page.getByRole("button", { name: "Enregistrer (Ctrl+S)", exact: true }).click()
|
||||
await page.waitForFunction(() => (window as any).writeStarted)
|
||||
await page.evaluate(() => {
|
||||
const editor = (window as any).monaco.editor.getEditors().find((e: any) => e.getModel()?.uri.toString().includes("workspace-editor"))
|
||||
editor.setValue((window as any).originalText)
|
||||
})
|
||||
await eye().click()
|
||||
await eye().click()
|
||||
await page.locator('.workspace-file-view .view-line').first().waitFor()
|
||||
assert.equal(await page.getByRole("button", { name: "Enregistrer (Ctrl+S)", exact: true }).isDisabled(), true)
|
||||
await page.evaluate(() => (window as any).releaseWrite())
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.some((call: any) => call.kind === "save"))
|
||||
await page.waitForFunction(() => !document.querySelector<HTMLButtonElement>('[aria-label="Enregistrer (Ctrl+S)"]')?.disabled)
|
||||
// Also exercise the authoritative reader reload after completion rather
|
||||
// than only checking its hook's dirty flag before invalidation has run.
|
||||
await page.evaluate(() => (window as any).fixture.invalidate())
|
||||
await page.waitForTimeout(200)
|
||||
assert.equal(await page.evaluate(() => {
|
||||
const editor = (window as any).monaco.editor.getEditors().find((e: any) => e.getModel()?.uri.toString().includes("workspace-editor"))
|
||||
return editor.getValue() === (window as any).originalText
|
||||
}), true)
|
||||
assert.equal(await page.getByRole("button", { name: "Enregistrer (Ctrl+S)", exact: true }).isEnabled(), true)
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("the eye-opened Changes reader follows staging without changing the selected row", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1800, height: 960 } })
|
||||
try {
|
||||
await page.goto(url)
|
||||
await page.evaluate(async () => {
|
||||
const apiPath = "/src/lib/api-client.ts"
|
||||
const { serverApi } = await import(apiPath)
|
||||
const status = serverApi.fetchWorktreeGitStatus, stage = serverApi.stageWorktreeGitPaths
|
||||
let staged = false
|
||||
serverApi.fetchWorktreeGitStatus = async (...args: Parameters<typeof status>) => (await status(...args)).map(entry =>
|
||||
staged && entry.path.endsWith("git-history.css") ? { ...entry, stagedStatus: "modified", unstagedStatus: null } : entry)
|
||||
serverApi.stageWorktreeGitPaths = async (...args: Parameters<typeof stage>) => { const result = await stage(...args); staged = true; return result }
|
||||
})
|
||||
await page.getByRole("button", { name: "Changements", exact: true }).click()
|
||||
const selected = page.locator('.git-panel-file-row').filter({ hasText: "src/components/git-panel.tsx" })
|
||||
await selected.locator('.git-panel-file-main').click()
|
||||
const eye = page.getByRole("button", { name: "Aperçu du fichier · src/styles/panels/git-history.css", exact: true })
|
||||
await eye.click()
|
||||
await page.locator('.git-diff-content .line-insert').first().waitFor()
|
||||
await page.getByRole("button", { name: "Indexer le fichier · src/styles/panels/git-history.css", exact: true }).click()
|
||||
await page.waitForFunction(() => (window as any).fixture.target()?.scope === "staged")
|
||||
assert.equal(await selected.locator('.git-panel-file-main').getAttribute("aria-current"), "true")
|
||||
assert.equal(await eye.getAttribute("aria-pressed"), "true")
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.target().path), "src/styles/panels/git-history.css")
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("returning to an invalidated worktree lazily refreshes cached Workspace rows", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
try {
|
||||
await page.goto(url)
|
||||
await page.getByRole("treeitem", { name: "package.json", exact: true }).waitFor()
|
||||
const selector = page.getByRole("combobox", { name: "Worktree à consulter" })
|
||||
await selector.selectOption("review")
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.some((call: any) => call.kind === "files" && call.slug.endsWith("/review")))
|
||||
await page.evaluate(async () => {
|
||||
const apiPath = "/src/lib/api-client.ts"
|
||||
const { serverApi } = await import(apiPath)
|
||||
const list = serverApi.listWorkspaceFiles
|
||||
serverApi.listWorkspaceFiles = async (...args: Parameters<typeof list>) => {
|
||||
const entries = await list(...args)
|
||||
return args[2] === "/CodeNomad" && args[1] === "."
|
||||
? entries.map(entry => entry.path === "package.json" ? { ...entry, name: "new.json", path: "new.json" } : entry) : entries
|
||||
}
|
||||
;(window as any).fixture.invalidate()
|
||||
})
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.filter((call: any) => call.kind === "files" && call.slug.endsWith("/review")).length >= 2)
|
||||
await selector.selectOption("root")
|
||||
await page.getByRole("treeitem", { name: "new.json", exact: true }).waitFor()
|
||||
assert.equal(await page.getByRole("treeitem", { name: "package.json", exact: true }).count(), 0)
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("returning after a cancelled child refresh revalidates expanded Workspace descendants", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
try {
|
||||
await page.goto(url)
|
||||
const src = page.locator('[role="treeitem"][data-path="src"]')
|
||||
await src.click()
|
||||
await page.locator('[role="treeitem"][data-path="src/index.ts"]').waitFor()
|
||||
await page.evaluate(async () => {
|
||||
const apiPath = "/src/lib/api-client.ts"
|
||||
const { serverApi } = await import(apiPath)
|
||||
const list = serverApi.listWorkspaceFiles
|
||||
let hold = true
|
||||
serverApi.listWorkspaceFiles = async (...args: Parameters<typeof list>) => {
|
||||
const entries = await list(...args)
|
||||
if (args[2] === "/CodeNomad" && args[1] === "src") {
|
||||
if (hold) {
|
||||
;(window as any).childPending = true
|
||||
await new Promise<void>(resolve => { (window as any).releaseChild = () => { hold = false; resolve() } })
|
||||
}
|
||||
return entries.map(entry => entry.path === "src/index.ts" ? { ...entry, name: "new.ts", path: "src/new.ts" } : entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
;(window as any).fixture.invalidate()
|
||||
})
|
||||
await page.waitForFunction(() => (window as any).childPending)
|
||||
const selector = page.getByRole("combobox", { name: "Worktree à consulter" })
|
||||
await selector.selectOption("review")
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.some((call: any) => call.kind === "files" && call.slug.endsWith("/review")))
|
||||
await page.evaluate(() => (window as any).releaseChild())
|
||||
await selector.selectOption("root")
|
||||
await page.locator('[role="treeitem"][data-path="src/new.ts"]').waitFor()
|
||||
assert.equal(await page.locator('[role="treeitem"][data-path="src/index.ts"]').count(), 0)
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
before(async () => {
|
||||
prepareGitPrototypeAssets()
|
||||
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
|
||||
publicDir: fileURLToPath(new URL("../../src/renderer/public", import.meta.url)),
|
||||
plugins: [solid(), { name: "git-history-fixture", configureServer(s) {
|
||||
s.middlewares.use("/fixture", async (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/html")
|
||||
res.end(await s.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/git-history.tsx"></script></body></html>'))
|
||||
})
|
||||
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
|
||||
})
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => { await browser?.close(); await server?.close() })
|
||||
|
||||
test("Changes restores independent disclosures and Git actions above the staged files", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 850 } })
|
||||
try {
|
||||
await page.goto(url)
|
||||
await page.getByRole("button", { name: "Changements", exact: true }).click()
|
||||
const staged = page.locator('.git-drop-zone').nth(0)
|
||||
const unstaged = page.locator('.git-drop-zone').nth(1)
|
||||
const stagedHeader = staged.getByRole("button", { name: /Changements indexés/ })
|
||||
const unstagedHeader = unstaged.locator('.git-change-section-header')
|
||||
const input = staged.getByRole("textbox", { name: "Saisissez le message du commit" })
|
||||
await input.waitFor()
|
||||
assert.equal(await stagedHeader.getAttribute("aria-expanded"), "true")
|
||||
assert.equal(await unstagedHeader.getAttribute("aria-expanded"), "true")
|
||||
assert.equal(await page.locator('details.git-panel-actions').count(), 0)
|
||||
assert.equal(await staged.getByRole("group", { name: "Actions Git" }).evaluate(element =>
|
||||
Boolean(element.compareDocumentPosition(element.parentElement!.querySelector('.git-panel-file-row')!) & Node.DOCUMENT_POSITION_FOLLOWING)), true)
|
||||
const submit = staged.getByRole("button", { name: "Valider", exact: true })
|
||||
assert.equal(await submit.isDisabled(), true)
|
||||
await input.fill("Rétablir les sections Git")
|
||||
assert.equal(await submit.isEnabled(), true)
|
||||
const reads = await page.evaluate(() => (window as any).fixture.calls.filter((call: any) => call.kind === "status").length)
|
||||
await stagedHeader.click()
|
||||
assert.equal(await input.isVisible(), false)
|
||||
assert.equal(await unstaged.locator('.git-panel-file-row').isVisible(), true)
|
||||
await unstagedHeader.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
assert.equal(await unstagedHeader.getAttribute("aria-expanded"), "false")
|
||||
assert.equal(await unstaged.locator('.git-panel-file-row').isVisible(), false)
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.calls.filter((call: any) => call.kind === "status").length), reads)
|
||||
await page.getByRole("button", { name: "Workspace", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Changements", exact: true }).click()
|
||||
assert.equal(await stagedHeader.getAttribute("aria-expanded"), "false")
|
||||
assert.equal(await unstagedHeader.getAttribute("aria-expanded"), "false")
|
||||
await page.getByRole("button", { name: "Actualiser", exact: true }).click()
|
||||
await page.waitForFunction(reads => (window as any).fixture.calls.filter((call: any) => call.kind === "status").length > reads, reads)
|
||||
assert.equal(await stagedHeader.getAttribute("aria-expanded"), "false")
|
||||
await stagedHeader.click()
|
||||
assert.equal(await input.inputValue(), "Rétablir les sections Git")
|
||||
await submit.click()
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.some((call: any) => call.kind === "submit" && call.message === "Rétablir les sections Git"))
|
||||
await stagedHeader.click()
|
||||
await unstagedHeader.click()
|
||||
// A collapsed section keeps its header as a real pointer drop target.
|
||||
const source = await unstaged.locator('.git-panel-file-main').boundingBox()
|
||||
const target = await stagedHeader.boundingBox()
|
||||
assert.ok(source && target)
|
||||
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2 - 10, { steps: 4 })
|
||||
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.some((call: any) => call.kind === "stage" && call.path === "src/styles/panels/git-history.css"))
|
||||
await stagedHeader.click()
|
||||
await staged.getByRole("button", { name: "Retirer de l'index · src/components/git-panel.tsx", exact: true }).click()
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.some((call: any) => call.kind === "unstage"))
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("Workspace actions measure the whole row and share the subtle rollover across Files modes", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0.0.0" })
|
||||
try {
|
||||
await page.addInitScript(() => {
|
||||
;(window as any).__CODENOMAD_RUNTIME_HOST__ = "electron"
|
||||
;(window as any).__CODENOMAD_WINDOW_CONTEXT__ = "local"
|
||||
})
|
||||
await page.goto(url)
|
||||
const row = page.locator('[role="treeitem"][data-path="README.md"]')
|
||||
const actions = row.locator('.file-row-actions')
|
||||
const setWidth = async (width: number) => {
|
||||
await page.locator('aside').evaluate((element, width) => {
|
||||
element.style.width = `${width}px`; element.style.minWidth = "0"; element.style.maxWidth = "none"
|
||||
}, width)
|
||||
}
|
||||
await setWidth(500)
|
||||
await row.hover()
|
||||
await page.waitForFunction(() => document.querySelector('[data-path="README.md"] .file-row-actions')?.getAttribute('data-compact') === "false")
|
||||
assert.equal(await actions.locator('.file-row-inline-actions button:visible').count(), 3)
|
||||
assert.equal(await actions.locator('.file-row-pinned-actions button:visible').count(), 1)
|
||||
assert.equal(await actions.locator('.action-overflow-trigger').isVisible(), false)
|
||||
const rollover = await row.evaluate(element => getComputedStyle(element).backgroundImage)
|
||||
assert.match(rollover, /linear-gradient/)
|
||||
await row.focus()
|
||||
const selectedBackground = await row.evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
await page.mouse.move(10, 10)
|
||||
assert.equal(await row.evaluate(element => getComputedStyle(element).backgroundColor), selectedBackground)
|
||||
await actions.locator('.file-row-inline-actions button').first().hover()
|
||||
assert.equal(await actions.locator('.file-row-inline-actions button').first().evaluate(element => getComputedStyle(element).backgroundImage), rollover)
|
||||
await setWidth(160)
|
||||
const menu = actions.locator('.action-overflow-trigger')
|
||||
await menu.waitFor()
|
||||
assert.equal(await actions.locator('.file-row-inline-actions').evaluate(element => (element as HTMLElement).inert), true)
|
||||
await menu.click()
|
||||
await page.getByRole("menu").waitFor()
|
||||
await setWidth(500)
|
||||
assert.equal(await actions.getAttribute("data-compact"), "true", "open menu remains mounted after resize")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.waitForFunction(() => document.querySelector('[data-path="README.md"] .file-row-actions')?.getAttribute('data-compact') === "false")
|
||||
assert.equal(await actions.locator('.file-row-inline-actions').evaluate(element => (element as HTMLElement).inert), false)
|
||||
await page.evaluate(() => { document.documentElement.dir = "rtl"; document.body.style.zoom = "1.25" })
|
||||
await row.hover()
|
||||
assert.equal(await actions.getAttribute("data-compact"), "false")
|
||||
await page.getByRole("button", { name: "Changements", exact: true }).click()
|
||||
const file = page.locator('.git-panel-file-row').first()
|
||||
await file.hover()
|
||||
assert.equal(await file.evaluate(element => getComputedStyle(element).backgroundImage), rollover)
|
||||
if (process.env.CODENOMAD_GIT_CAPTURE) await page.screenshot({ path: `${process.env.CODENOMAD_GIT_CAPTURE}/changes-restored.png` })
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("clicked previews bypass blocked background scans and follow the live palette", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 850 } })
|
||||
try {
|
||||
await page.goto(url)
|
||||
await page.getByRole("treeitem", { name: "package.json", exact: true }).waitFor()
|
||||
await page.evaluate(async () => {
|
||||
const queuePath = "/src/lib/background-read-queue.ts"
|
||||
const { backgroundReads } = await import(queuePath)
|
||||
const releases: Array<() => void> = []
|
||||
for (let i = 0; i < 2; i++) void backgroundReads.run(new AbortController().signal,
|
||||
() => new Promise<void>(resolve => releases.push(resolve)))
|
||||
;(window as any).releaseScans = () => releases.forEach(release => release())
|
||||
})
|
||||
const start = performance.now()
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · package.json", exact: true }).click()
|
||||
await page.locator(".workspace-file-view .view-line").first().waitFor({ timeout: 5000 })
|
||||
console.log(`Cold file reader with blocked scans: ${Math.round(performance.now() - start)}ms`)
|
||||
await page.evaluate(() => (window as any).releaseScans())
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty("--surface-base", "#24313f")
|
||||
document.documentElement.style.setProperty("--status-success", "#68ac93")
|
||||
})
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector(".monaco-editor-background")!).backgroundColor === "rgb(36, 49, 63)")
|
||||
await page.getByRole("button", { name: /Changements/, exact: true }).click()
|
||||
assert.equal(await page.getByRole("button", { name: "Changements", exact: true }).innerText(), "Changements")
|
||||
await page.locator(".git-panel-count").waitFor()
|
||||
assert.equal(await page.locator(".git-panel-switch button").nth(1).evaluate(el => getComputedStyle(el).borderInlineStartWidth), "1px")
|
||||
assert.equal(await page.locator(".git-panel").evaluate(el => {
|
||||
const sample = document.createElement("span")
|
||||
sample.style.backgroundColor = "var(--surface-secondary)"
|
||||
el.append(sample)
|
||||
const matches = getComputedStyle(el).backgroundColor === getComputedStyle(sample).backgroundColor
|
||||
sample.remove()
|
||||
return matches
|
||||
}), true)
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · src/styles/panels/git-history.css", exact: true }).click()
|
||||
await page.locator(".line-insert").first().waitFor()
|
||||
const inserted = await page.locator(".line-insert").first().evaluate(el => getComputedStyle(el).backgroundColor)
|
||||
assert.match(inserted, /104, 172, 147/)
|
||||
if (process.env.CODENOMAD_GIT_CAPTURE) await page.screenshot({ path: `${process.env.CODENOMAD_GIT_CAPTURE}/palette-diff.png` })
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("the shared eye toggles previews independently from row selection, including Git images", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
try {
|
||||
await page.goto(`${url}?images=1`)
|
||||
await page.getByRole("treeitem", { name: "image.png", exact: true }).click()
|
||||
assert.equal(await page.locator('.workspace-file-view').count(), 0, "row click selects without opening a reader")
|
||||
const eye = () => page.getByRole("button", { name: "Aperçu du fichier · image.png", exact: true })
|
||||
await eye().click()
|
||||
await page.waitForFunction(() => { const img = document.querySelector<HTMLImageElement>('.workspace-image img'); return img?.complete && img.naturalWidth > 0 })
|
||||
assert.equal(await eye().getAttribute("aria-pressed"), "true")
|
||||
const activeColor = await eye().evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
assert.notEqual(activeColor, "rgba(0, 0, 0, 0)")
|
||||
await eye().click()
|
||||
assert.equal(await page.locator('.workspace-file-view').count(), 0)
|
||||
assert.equal(await eye().getAttribute("aria-pressed"), "false")
|
||||
await page.getByRole("button", { name: "Changements", exact: true }).click()
|
||||
await page.locator('.git-panel-file-main[title="image.png"]').click()
|
||||
assert.equal(await page.locator('.git-diff-view').count(), 0)
|
||||
await eye().click()
|
||||
await page.waitForFunction(() => [...document.querySelectorAll<HTMLImageElement>('.git-image-diff img')].length === 2
|
||||
&& [...document.querySelectorAll<HTMLImageElement>('.git-image-diff img')].every(img => img.complete && img.naturalWidth > 0))
|
||||
assert.equal(await eye().getAttribute("aria-pressed"), "true")
|
||||
assert.equal(await eye().evaluate(element => getComputedStyle(element).backgroundColor), activeColor)
|
||||
const selectedRow = page.locator('.git-panel-file-row', { has: page.locator('.git-panel-file-main[title="image.png"]') })
|
||||
assert.ok((await selectedRow.getAttribute('class'))?.includes('git-panel-file-selected'))
|
||||
assert.equal(await selectedRow.locator('.git-panel-file-main').evaluate(element => getComputedStyle(element).backgroundColor), "rgba(0, 0, 0, 0)", "selection background belongs to the full row, beneath its buttons")
|
||||
await page.getByRole("button", { name: "Retour à la conversation" }).click()
|
||||
assert.equal(await eye().getAttribute("aria-pressed"), "false")
|
||||
await page.getByRole("button", { name: "Commits", exact: true }).click()
|
||||
await page.getByRole("button", { name: /Make Git history the starting point/ }).click()
|
||||
await page.locator('.git-commit-files .git-panel-file-main[title="image.png"]').click()
|
||||
assert.equal(await page.locator('.git-image-diff').count(), 0)
|
||||
await eye().click()
|
||||
await page.locator('.git-image-diff img').first().waitFor()
|
||||
assert.equal(await eye().getAttribute("aria-pressed"), "true")
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.target().commit), "1".repeat(40))
|
||||
if (process.env.CODENOMAD_GIT_CAPTURE) await page.screenshot({ path: `${process.env.CODENOMAD_GIT_CAPTURE}/git-image-preview.png` })
|
||||
await eye().click()
|
||||
assert.equal(await page.locator('.git-image-diff').count(), 0)
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("reopening an ancestor revalidates its expanded descendants without reading collapsed folders", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
await page.goto(url)
|
||||
await page.getByRole("treeitem", { name: "src", exact: true }).click()
|
||||
await page.getByRole("treeitem", { name: "components", exact: true }).click()
|
||||
await page.getByRole("treeitem", { name: "git-panel.tsx", exact: true }).waitFor()
|
||||
await page.getByRole("treeitem", { name: "src", exact: true }).click()
|
||||
await page.evaluate(async () => {
|
||||
const modulePath = "/src/lib/api-client.ts"
|
||||
const { serverApi } = await import(modulePath)
|
||||
const read = serverApi.listWorkspaceFiles
|
||||
serverApi.listWorkspaceFiles = async (...args: any[]) => {
|
||||
const entries = await read(...args)
|
||||
return args[1] === "src/components"
|
||||
? [{ name: "new.ts", path: "src/components/new.ts", type: "file" }] : entries
|
||||
}
|
||||
;(window as any).fixture.invalidate()
|
||||
})
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.filter((call: any) => call.kind === "files" && call.path === ".").length >= 2)
|
||||
await page.getByRole("treeitem", { name: "src", exact: true }).click()
|
||||
await page.getByRole("treeitem", { name: "new.ts", exact: true }).waitFor()
|
||||
assert.equal(await page.getByRole("treeitem", { name: "git-panel.tsx", exact: true }).count(), 0)
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.calls.some((call: any) => call.kind === "files" && call.path === "src/styles")), false)
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("the shared branch label follows current inventory while history is inactive", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
await page.goto(url)
|
||||
await page.getByRole("button", { name: "Commits", exact: true }).click()
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.some((call: any) => call.kind === "history"))
|
||||
await page.getByRole("button", { name: /Changements/ }).click()
|
||||
await page.evaluate(async () => {
|
||||
const apiPath = "/src/lib/api-client.ts", storePath = "/src/stores/worktrees.ts"
|
||||
const { serverApi } = await import(apiPath), { reloadWorktrees } = await import(storePath)
|
||||
const previous = await serverApi.fetchWorktrees("git-prototype")
|
||||
serverApi.fetchWorktrees = async () => ({ ...previous, worktrees: previous.worktrees.map((entry: any) => ({ ...entry, branch: "new-current-branch" })) })
|
||||
await reloadWorktrees("git-prototype")
|
||||
})
|
||||
await page.waitForFunction(() => document.querySelector(".git-panel-context")?.textContent?.includes("new-current-branch"))
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.calls.filter((call: any) => call.kind === "history").length), 1)
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("history and local changes open the central diff and preserve the conversation draft", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
try {
|
||||
await page.route("https://cdn.jsdelivr.net/**", route => route.abort())
|
||||
await page.goto(url)
|
||||
const history = page.getByRole("button", { name: "Commits", exact: true })
|
||||
await history.waitFor()
|
||||
assert.equal(await page.getByRole("button", { name: "Workspace", exact: true }).getAttribute("aria-pressed"), "true")
|
||||
await history.click()
|
||||
await page.getByRole("textbox", { name: "Brouillon" }).fill("Ne pas perdre ce brouillon")
|
||||
if (process.env.CODENOMAD_GIT_CAPTURE) await page.screenshot({ path: `${process.env.CODENOMAD_GIT_CAPTURE}/history.png` })
|
||||
await page.getByRole("button", { name: /Make Git history the starting point/ }).click()
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · src/components/git-panel.tsx", exact: true }).click()
|
||||
await page.locator(".git-diff-view .monaco-diff-editor").waitFor()
|
||||
await page.locator(".git-diff-view .line-insert").first().waitFor()
|
||||
assert.ok(await page.locator("main .git-diff-view").isVisible())
|
||||
if (process.env.CODENOMAD_GIT_CAPTURE) await page.screenshot({ path: `${process.env.CODENOMAD_GIT_CAPTURE}/commit-diff.png` })
|
||||
await page.getByRole("button", { name: "Retour à la conversation" }).click()
|
||||
assert.equal(await page.getByRole("textbox", { name: "Brouillon" }).inputValue(), "Ne pas perdre ce brouillon")
|
||||
await page.getByRole("button", { name: /Changements/ }).click()
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · src/styles/panels/git-history.css", exact: true }).click()
|
||||
await page.locator(".git-diff-view .monaco-diff-editor").waitFor()
|
||||
await page.locator(".git-diff-view .line-insert").first().waitFor()
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.target().scope), "unstaged")
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.target().commit), undefined)
|
||||
if (process.env.CODENOMAD_GIT_CAPTURE) await page.screenshot({ path: `${process.env.CODENOMAD_GIT_CAPTURE}/local-diff.png` })
|
||||
await page.getByRole("button", { name: "Commits", exact: true }).first().click()
|
||||
assert.ok(await page.locator(".git-commit-summary").isVisible(), "switching views retains the selected commit")
|
||||
await page.getByRole("combobox", { name: "Worktree à consulter" }).selectOption("review")
|
||||
await page.waitForFunction(() => !document.querySelector(".git-diff-view"))
|
||||
assert.ok(await page.getByRole("button", { name: /Merge pull request/ }).isVisible())
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("a slow commit cannot replace the history after browsing another worktree", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
await page.goto(url)
|
||||
await page.getByRole("button", { name: "Commits", exact: true }).click()
|
||||
await page.getByRole("button", { name: /Make Git history/ }).waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.holdCommit())
|
||||
await page.getByRole("button", { name: /Make Git history/ }).click()
|
||||
await page.getByRole("combobox", { name: "Worktree à consulter" }).selectOption("review")
|
||||
await page.evaluate(() => (window as any).fixture.releaseCommit())
|
||||
await page.getByRole("button", { name: /Merge pull request/ }).waitFor()
|
||||
assert.equal(await page.locator(".git-commit-summary").count(), 0)
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("Workspace retains its expanded tree across modes and previews source, Markdown and images above the draft", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
try {
|
||||
await page.route("https://cdn.jsdelivr.net/**", route => route.abort())
|
||||
await page.goto(url)
|
||||
await page.getByRole("textbox", { name: "Brouillon" }).fill("Brouillon conservé")
|
||||
await page.getByRole("treeitem", { name: ".github", exact: true }).click()
|
||||
await page.getByRole("treeitem", { name: "workflows", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · .github/workflows/update-winget.yml", exact: true }).click()
|
||||
await page.locator(".workspace-file-view .view-lines").filter({ hasText: "Update Winget" }).waitFor()
|
||||
assert.equal(await page.evaluate(() => (window as any).monaco.editor.getEditors().filter((editor: any) => editor.getModel()?.uri.toString().includes("workspace-editor")).every((editor: any) => !editor.getRawOptions().readOnly)), true)
|
||||
if (process.env.CODENOMAD_GIT_CAPTURE) await page.screenshot({ path: `${process.env.CODENOMAD_GIT_CAPTURE}/workspace-source.png` })
|
||||
await page.getByRole("button", { name: /Changements/ }).click()
|
||||
await page.getByRole("button", { name: "Workspace", exact: true }).click()
|
||||
assert.ok(await page.getByRole("treeitem", { name: "update-winget.yml", exact: true }).isVisible())
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · README.md", exact: true }).click()
|
||||
await page.locator(".workspace-markdown").getByRole("heading", { name: "CodeNomad", exact: true }).waitFor()
|
||||
await page.getByRole("treeitem", { name: "dev-docs", exact: true }).click()
|
||||
await page.getByRole("treeitem", { name: "ui-harmonization-demo", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · dev-docs/ui-harmonization-demo/palette.svg", exact: true }).click()
|
||||
await page.waitForFunction(() => { const img = document.querySelector<HTMLImageElement>(".workspace-image img"); return img?.complete && img.naturalWidth > 0 })
|
||||
assert.equal(await page.getByRole("textbox", { name: "Brouillon" }).inputValue(), "Brouillon conservé")
|
||||
if (process.env.CODENOMAD_GIT_CAPTURE) await page.screenshot({ path: `${process.env.CODENOMAD_GIT_CAPTURE}/workspace-image.png` })
|
||||
await page.getByRole("button", { name: "Retour à la conversation" }).click()
|
||||
assert.equal(await page.locator(".workspace-file-view").count(), 0)
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("real SessionView keeps its composer while file previews are fenced by session and worktree", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
await page.route("**/api/**", route => route.fulfill({ json: {} }))
|
||||
try {
|
||||
await page.goto(`${url}?session=1`)
|
||||
const composer = page.locator(".session-view textarea:visible").first()
|
||||
await composer.fill("Mon brouillon dans le vrai composeur")
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · README.md", exact: true }).click()
|
||||
await page.locator(".workspace-markdown h1").waitFor()
|
||||
assert.equal(await composer.inputValue(), "Mon brouillon dans le vrai composeur")
|
||||
await page.getByRole("button", { name: "Retour à la conversation" }).click()
|
||||
assert.equal(await composer.inputValue(), "Mon brouillon dans le vrai composeur")
|
||||
await page.evaluate(() => (window as any).fixture.holdFile())
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · README.md", exact: true }).click()
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.filter((call: any) => call.kind === "file").length >= 2)
|
||||
await page.evaluate(() => { (window as any).fixture.switchSession("other"); (window as any).fixture.releaseFile() })
|
||||
await page.waitForFunction(() => !document.querySelector(".workspace-file-view"))
|
||||
assert.equal(await composer.inputValue(), "")
|
||||
await page.evaluate(() => (window as any).fixture.switchSession("session"))
|
||||
assert.equal(await composer.inputValue(), "Mon brouillon dans le vrai composeur")
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · README.md", exact: true }).click()
|
||||
await page.locator(".workspace-markdown h1").waitFor()
|
||||
const before = await page.evaluate(() => (window as any).fixture.calls.filter((call: any) => call.kind === "file").length)
|
||||
await page.evaluate(() => (window as any).fixture.invalidate())
|
||||
await page.waitForFunction(before => (window as any).fixture.calls.filter((call: any) => call.kind === "file").length > before, before)
|
||||
await page.getByRole("combobox", { name: "Worktree à consulter" }).selectOption("review")
|
||||
assert.equal(await page.locator(".workspace-file-view").count(), 0)
|
||||
assert.equal(await composer.inputValue(), "Mon brouillon dans le vrai composeur")
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("central diff inserts local lines and revision-qualified history into the real session composer", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.stack ?? error.message))
|
||||
await page.route("**/api/**", route => route.fulfill({ json: {} }))
|
||||
try {
|
||||
await page.goto(`${url}?session=1`)
|
||||
const composer = page.locator(".session-view textarea.prompt-input")
|
||||
await composer.fill("Conserver ce brouillon")
|
||||
await page.getByRole("button", { name: /Changements/ }).click()
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · src/components/git-panel.tsx", exact: true }).click()
|
||||
const modifiedLine = page.locator(".git-diff-view .editor.modified .view-line").filter({ hasText: 'title: "Git"' })
|
||||
await modifiedLine.click()
|
||||
await page.keyboard.press("Home")
|
||||
await page.keyboard.press("Shift+End")
|
||||
const insert = page.getByRole("button", { name: "Ajouter au prompt", exact: true })
|
||||
await insert.click()
|
||||
assert.match(await composer.inputValue(), /Conserver ce brouillon/)
|
||||
assert.match(await composer.inputValue(), /Git Diff: Worktree: \/CodeNomad : File: src\/components\/git-panel.tsx : 3-3/)
|
||||
assert.equal(await composer.evaluate(element => element === document.activeElement), true)
|
||||
|
||||
// Native editor keyboard selection must keep the full range, not just the hovered line.
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · src/styles/panels/git-history.css", exact: true }).click()
|
||||
await modifiedLine.click()
|
||||
await page.keyboard.press("Control+Home")
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await page.keyboard.press("Shift+ArrowDown")
|
||||
await page.keyboard.press("Shift+ArrowDown")
|
||||
await insert.click()
|
||||
assert.match(await composer.inputValue(), /Git Diff: Worktree: \/CodeNomad : File: src\/styles\/panels\/git-history.css : 3-5/)
|
||||
|
||||
await page.getByRole("button", { name: "Commits", exact: true }).first().click()
|
||||
await page.getByRole("button", { name: /Make Git history the starting point/ }).click()
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · src/components/git-panel.tsx", exact: true }).click()
|
||||
await modifiedLine.click()
|
||||
await page.keyboard.press("Home")
|
||||
await page.keyboard.press("Shift+End")
|
||||
await insert.click()
|
||||
assert.ok((await composer.inputValue()).includes(`Git Diff: Commit: ${"1".repeat(40)} : Worktree: /CodeNomad : File: src/components/git-panel.tsx : 3-3`))
|
||||
// The inventory's native service path is the prompt authority, not the
|
||||
// Windows filesystem translation used by the preview reader for WSL.
|
||||
await page.evaluate(async () => {
|
||||
const apiPath = "/src/lib/api-client.ts", storePath = "/src/stores/worktrees.ts"
|
||||
const { serverApi } = await import(apiPath), { reloadWorktrees } = await import(storePath)
|
||||
const previous = await serverApi.fetchWorktrees("git-prototype")
|
||||
serverApi.fetchWorktrees = async () => ({ ...previous, worktrees: previous.worktrees.map((entry: any) => entry.slug === "review"
|
||||
? { ...entry, directory: "\\\\wsl.localhost\\Ubuntu\\home\\dev\\review", serviceDirectory: "/home/dev/review" } : entry) })
|
||||
await reloadWorktrees("git-prototype")
|
||||
})
|
||||
await page.getByRole("combobox", { name: "Worktree à consulter" }).selectOption("review")
|
||||
await page.getByRole("button", { name: /Changements/ }).click()
|
||||
await page.getByRole("button", { name: "Aperçu du fichier · src/components/git-panel.tsx", exact: true }).click()
|
||||
await modifiedLine.click()
|
||||
await page.keyboard.press("Home")
|
||||
await page.keyboard.press("Shift+End")
|
||||
await insert.click()
|
||||
const draft = await composer.inputValue()
|
||||
assert.ok(draft.includes("Git Diff: Worktree: /home/dev/review : File: src/components/git-panel.tsx : 3-3"))
|
||||
assert.equal(draft.includes("wsl.localhost"), false)
|
||||
assert.equal(await page.evaluate(async () => {
|
||||
const storePath = "/src/stores/session-state.ts"
|
||||
const { sessions } = await import(storePath)
|
||||
return sessions().get("git-prototype").get("session").location.directory
|
||||
}), "/CodeNomad")
|
||||
await page.getByRole("button", { name: "Retour à la conversation" }).click()
|
||||
assert.equal(await composer.inputValue(), draft)
|
||||
await page.evaluate(() => (window as any).fixture.switchSession("other"))
|
||||
assert.equal(await composer.inputValue(), "")
|
||||
await page.evaluate(() => (window as any).fixture.switchSession("session"))
|
||||
assert.equal(await composer.inputValue(), draft)
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("Workspace keyboard focus survives lazy expansion and refresh, including RTL", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 800, height: 800 } })
|
||||
try {
|
||||
await page.goto(url)
|
||||
const folder = page.getByRole("treeitem", { name: ".github", exact: true })
|
||||
await folder.focus()
|
||||
await page.keyboard.press("ArrowRight")
|
||||
await page.getByRole("treeitem", { name: "workflows", exact: true }).waitFor()
|
||||
assert.equal(await folder.evaluate(element => element === document.activeElement), true)
|
||||
await page.keyboard.press("ArrowRight")
|
||||
assert.equal(await page.getByRole("treeitem", { name: "workflows", exact: true }).evaluate(element => element === document.activeElement), true)
|
||||
await page.evaluate(() => (window as any).fixture.invalidate())
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.filter((call: any) => call.kind === "files" && call.path === ".github").length >= 2)
|
||||
assert.equal(await page.getByRole("treeitem", { name: "workflows", exact: true }).evaluate(element => element === document.activeElement), true)
|
||||
await page.evaluate(() => { document.documentElement.dir = "rtl" })
|
||||
await page.keyboard.press("ArrowRight")
|
||||
assert.equal(await folder.evaluate(element => element === document.activeElement), true)
|
||||
await page.keyboard.press("ArrowRight")
|
||||
assert.equal(await folder.getAttribute("aria-expanded"), "false")
|
||||
} finally { await page.close() }
|
||||
})
|
||||
29
scripts/preview-git-history.mjs
Normal file
29
scripts/preview-git-history.mjs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Interactive design fixture using the production Git panel and central reader.
|
||||
// Synthetic data only; does not connect to OpenCode or mutate a repository.
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { createServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { prepareGitPrototypeAssets } from "../packages/ui/tests/browser/fixtures/git-history-assets.mjs"
|
||||
|
||||
prepareGitPrototypeAssets()
|
||||
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
root: fileURLToPath(new URL("../packages/ui/", import.meta.url)),
|
||||
publicDir: fileURLToPath(new URL("../packages/ui/src/renderer/public", import.meta.url)),
|
||||
plugins: [solid(), {
|
||||
name: "git-history-prototype",
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/fixture", async (_request, response) => {
|
||||
response.setHeader("Content-Type", "text/html")
|
||||
response.end(await server.transformIndexHtml("/fixture", '<html><head><title>CodeNomad · Files prototype</title></head><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/git-history.tsx"></script></body></html>'))
|
||||
})
|
||||
},
|
||||
}],
|
||||
resolve: { dedupe: ["solid-js"] },
|
||||
optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
|
||||
})
|
||||
await server.listen()
|
||||
console.log(`Git prototype: http://127.0.0.1:${server.httpServer.address().port}/fixture`)
|
||||
for (const event of ["SIGINT", "SIGTERM"]) process.on(event, () => { void server.close().then(() => process.exit()) })
|
||||
Loading…
Add table
Add a link
Reference in a new issue