Refactor Git Changes workflow and diff handling (#311)
# Git Changes PR Review Context Fixes: #310 ## Purpose of this document This document is intended to give a PR reviewer or gatekeeper enough neutral context to review the Git Changes feature series accurately. ## BEFORE/AFTER SNAPSHOT: <img width="835" height="1163" alt="image" src="https://github.com/user-attachments/assets/463d6f8c-1a6b-4cf0-8ab8-44a92c534ca5" /> It distinguishes: 1. the intended scope of the work 2. implementation choices that were deliberate 3. behaviors that were explicitly tested and accepted during development 4. remaining follow-up areas that were not part of the required intent It should not be treated as a request to approve the PR automatically. It exists to reduce false-positive review findings caused by missing context. --- ## High-level scope The work in this series refactors and extends the existing `Git Changes` tab in the right panel. The intended feature scope includes: 1. grouped staged / unstaged change presentation 2. correct section-aware diff loading 3. per-file stage / unstage controls 4. commit message compose box and commit action for staged changes 5. prompt-context insertion from the Git diff viewer 6. auto-refresh behavior that reduces dependence on the manual refresh button This work is intentionally implemented inside the existing Git Changes vertical slice rather than as a new SCM subsystem. --- ## Files and areas intentionally changed ### Server / API surface The following server areas were intentionally extended: 1. `packages/server/src/api-types.ts` 2. `packages/server/src/events/bus.ts` 3. `packages/server/src/server/http-server.ts` 4. `packages/server/src/server/routes/workspaces.ts` 5. `packages/server/src/workspaces/git-status.ts` 6. `packages/server/src/workspaces/git-mutations.ts` 7. `packages/server/src/workspaces/worktree-directory.ts` 8. `packages/server/src/workspaces/instance-events.ts` ### UI surface The following UI areas were intentionally extended: 1. `packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx` 2. `packages/ui/src/components/instance/instance-shell2.tsx` 3. `packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx` 4. `packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts` 5. `packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx` 6. `packages/ui/src/components/instance/shell/right-panel/types.ts` 7. `packages/ui/src/components/instance/shell/storage.ts` 8. `packages/ui/src/components/prompt-input.tsx` 9. `packages/ui/src/components/prompt-input/types.ts` 10. `packages/ui/src/components/session/session-view.tsx` 11. `packages/ui/src/lib/api-client.ts` 12. `packages/ui/src/lib/i18n/messages/*/instance.ts` 13. `packages/ui/src/styles/panels/right-panel.css` --- ## Intentional product and architecture decisions The following outcomes were deliberate and should not be flagged as issues merely because they exist. ### Git status / diff architecture 1. The UI does not rely only on the proxied OpenCode `file.status()` payload. 2. CodeNomad adds server-backed worktree Git status and diff endpoints to expose staged / unstaged semantics correctly. 3. Server-backed worktree mutation endpoints were added for: - stage - unstage - commit 4. The existing event bus / SSE channel is reused for Git invalidation, instead of adding a bespoke invalidation route. ### Git Changes UI structure 1. The file list is grouped into: - `Staged Changes` - `Changes` 2. Both sections are collapsible. 3. Section open state is persisted. 4. The same file may appear in both sections when Git state genuinely requires that. 5. Rows are filename-first, with parent path as secondary text. 6. Rows are intentionally compact compared to the original flat list. ### Diff behavior 1. Diff loading is section-aware. 2. Deleted files are supported in grouped mode. 3. Binary files are treated as non-line-oriented in the diff viewer. 4. Binary diffs suppress line-based prompt-context affordances. ### Stage / unstage / commit workflow 1. Stage and unstage are per-file row actions. 2. Bulk stage-all / unstage-all was intentionally not added. 3. The commit compose box is intentionally rendered inside the `Staged Changes` section. 4. The commit button is intentionally overlaid inside the commit input area. 5. The current commit compose flow is minimal by design: - no push - no amend flow - no branch management ### Prompt-context insertion 1. Prompt insertion is intentionally an HTML comment marker, not a full diff payload. 2. The expected inserted form is: `<!-- Git change context: <path> lines X-Y -->` 3. The trigger UI is intentionally a seam/gutter action in the Monaco diff viewer, not a toolbar button. ### Row action reveal behavior 1. Stage / unstage row actions are intentionally hover-revealed on hover-capable layouts. 2. The row action reveal intentionally uses: - delayed hide - slight stats fade/shift - compact idle width 3. On non-hover layouts, the action remains visible for reliability. ### Auto-refresh behavior The accepted refresh model is intentionally hybrid: 1. refresh on Git Changes tab activation 2. 20-second polling only while the Git Changes tab is active 3. immediate invalidation from completed raw tool events for: - `write` - `edit` - `apply_patch` This hybrid model is intentional. Polling remains as a fallback even after tool-event invalidation. --- ## Behaviors explicitly tested during development The following behaviors were explicitly exercised during development and used to guide fixes. ### Grouped staged / unstaged behavior 1. files appear in the correct staged / unstaged sections 2. section collapse / expand works 3. collapse state persists 4. line counts are section-specific ### Diff behavior 1. staged diff loads differently from unstaged diff 2. deleted-file handling was verified and corrected 3. binary-file rendering was corrected to avoid line-oriented behavior 4. untracked binary files no longer report fake text line counts ### Mutation behavior 1. per-file stage works from `Changes` 2. per-file unstage works from `Staged Changes` 3. stage / unstage selection remapping was exercised and corrected 4. unborn-repo unstage behavior was explicitly hardened ### Prompt-context behavior 1. selected line / range insertion was tested 2. button placement in the Monaco seam/gutter was iterated and verified ### Auto-refresh behavior 1. tab-activation refresh was tested 2. 20-second active-tab polling was tested 3. raw completed tool invalidation was tested in the running UI for: - `write` - `edit` - `apply_patch` 4. stale async overwrite and stale selection restoration bugs were found and fixed through review/testing --- ## Review findings that were investigated and are no longer intended blocker topics The following areas were previously raised by strict reviews and then either fixed or determined to be acceptable within scope. ### Fixed in the current series 1. duplicate stage / unstage firing 2. stale diff response overwriting newer selection 3. passive refresh restoring a stale selection 4. instance-wide invalidation overreach 5. selected diff staying stale after tool invalidation 6. worktree-switch status races 7. unhandled rejection risk from async invalidation publication 8. queued invalidation intent being lost during in-flight refresh 9. `git-diff` path traversal / absolute path boundary issue ### Investigated and considered non-blocking within current intent 1. split add/delete presentation for tracked rename behavior - this was compared against VS Code behavior during manual testing - no stage/unstage corruption was observed in the tested flow - this is currently treated as a representation tradeoff, not a proven blocker --- ## Remaining non-blocker follow-up areas The following are still reasonable follow-up topics, but they were not part of the required blocker-fix scope. 1. normalize directory-to-worktree matching more aggressively on Windows so tool invalidation works more reliably from nested directories or path-format variations 2. improve keyboard discoverability of hover-revealed stage / unstage actions 3. reserve textarea space for the overlaid commit button if the overlay tradeoff is reconsidered 4. reduce size/complexity in: - `RightPanel.tsx` - `right-panel.css` 5. tighten raw SSE tool-event parsing into a more explicit helper if that event bridge grows further These follow-ups should not be interpreted as evidence that the core implementation is incomplete unless a reviewer finds a new concrete failure. --- ## Suggested review focus If a gatekeeper or reviewer is evaluating this PR, the most useful focus areas are: 1. whether staged / unstaged behavior is correct for normal Git workflows 2. whether the new server worktree Git endpoints remain narrowly scoped 3. whether auto-refresh remains bounded to the active Git Changes context 4. whether the explicit fixes for stale async behavior and invalidation races are sufficient 5. whether any unintentional server boundary broadening or state corruption remains Less useful review topics, unless tied to a concrete failure, are: 1. preference disagreements with accepted prompt insertion format 2. preference disagreements with the overlaid commit button placement 3. preference disagreements with keeping polling fallback alongside tool invalidation 4. objections to server-backed Git endpoints purely because they add surface area --- ## Summary This series intentionally evolves the existing Git Changes tab into a more complete source-control workflow for: 1. grouped staged / unstaged inspection 2. section-aware diffs 3. per-file staging and unstaging 4. commit composition for staged changes 5. prompt-context insertion from Git diffs 6. bounded auto-refresh for both passive viewing and agent-driven file mutations The intended review standard is to find concrete correctness, layering, or maintenance problems that remain after this series — not to re-argue the already accepted product choices listed above. --------- Co-authored-by: Shantur Rathore <i@shantur.com>
This commit is contained in:
@@ -15,6 +15,11 @@ import type {
|
||||
RemoteServerProbeRequest,
|
||||
RemoteServerProbeResponse,
|
||||
VoiceModeStateResponse,
|
||||
WorktreeGitCommitRequest,
|
||||
WorktreeGitCommitResponse,
|
||||
WorktreeGitDiffRequest,
|
||||
WorktreeGitMutationResponse,
|
||||
WorktreeGitPathsRequest,
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceDescriptor,
|
||||
WorkspaceFileResponse,
|
||||
@@ -26,6 +31,8 @@ import type {
|
||||
WorktreeListResponse,
|
||||
WorktreeMap,
|
||||
WorktreeCreateRequest,
|
||||
WorktreeGitDiffResponse,
|
||||
WorktreeGitStatusResponse,
|
||||
} from "../../../server/src/api-types"
|
||||
import { getClientIdentity } from "./client-identity"
|
||||
import { getLogger } from "./logger"
|
||||
@@ -98,6 +105,25 @@ function logHttp(message: string, context?: Record<string, unknown>) {
|
||||
httpLogger.info(message)
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response): Promise<string> {
|
||||
const text = await response.text()
|
||||
if (!text) return `Request failed with ${response.status}`
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { error?: unknown; message?: unknown }
|
||||
if (typeof parsed?.error === "string" && parsed.error.trim()) {
|
||||
return parsed.error
|
||||
}
|
||||
if (typeof parsed?.message === "string" && parsed.message.trim()) {
|
||||
return parsed.message
|
||||
}
|
||||
} catch {
|
||||
// Keep the original body for plain-text responses.
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
||||
const headers = normalizeHeaders(init?.headers)
|
||||
@@ -112,7 +138,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
const message = await readErrorMessage(response)
|
||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
@@ -141,7 +167,7 @@ async function requestRaw(path: string, init?: RequestInit): Promise<Response> {
|
||||
|
||||
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
const message = await readErrorMessage(response)
|
||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
@@ -282,6 +308,47 @@ export const serverApi = {
|
||||
},
|
||||
)
|
||||
},
|
||||
fetchWorktreeGitStatus(id: string, slug: string): Promise<WorktreeGitStatusResponse> {
|
||||
return request<WorktreeGitStatusResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-status`,
|
||||
)
|
||||
},
|
||||
fetchWorktreeGitDiff(id: string, slug: string, requestPayload: WorktreeGitDiffRequest): Promise<WorktreeGitDiffResponse> {
|
||||
const params = new URLSearchParams({ path: requestPayload.path, scope: requestPayload.scope })
|
||||
if (requestPayload.originalPath) {
|
||||
params.set("originalPath", requestPayload.originalPath)
|
||||
}
|
||||
return request<WorktreeGitDiffResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-diff?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
stageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise<WorktreeGitMutationResponse> {
|
||||
return request<WorktreeGitMutationResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-stage`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
},
|
||||
unstageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise<WorktreeGitMutationResponse> {
|
||||
return request<WorktreeGitMutationResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-unstage`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
},
|
||||
commitWorktreeGitChanges(id: string, slug: string, payload: WorktreeGitCommitRequest): Promise<WorktreeGitCommitResponse> {
|
||||
return request<WorktreeGitCommitResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-commit`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
||||
|
||||
@@ -131,6 +131,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.loading": "Loading git changes...",
|
||||
"instanceShell.gitChanges.empty": "No git changes yet.",
|
||||
"instanceShell.gitChanges.deleted": "Deleted",
|
||||
"instanceShell.gitChanges.binaryViewer": "Binary file cannot be displayed",
|
||||
"instanceShell.gitChanges.sections.staged": "Staged Changes",
|
||||
"instanceShell.gitChanges.sections.unstaged": "Changes",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Add to prompt",
|
||||
"instanceShell.gitChanges.actions.stage": "Stage file",
|
||||
"instanceShell.gitChanges.actions.unstage": "Unstage file",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Enter commit message",
|
||||
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||
"instanceShell.gitChanges.commit.submitting": "Committing...",
|
||||
"instanceShell.gitChanges.commit.success": "Commit created successfully",
|
||||
"instanceShell.gitChanges.commit.error": "Failed to create commit",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "File list",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
||||
|
||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
|
||||
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
|
||||
"instanceShell.gitChanges.deleted": "Eliminado",
|
||||
"instanceShell.gitChanges.binaryViewer": "No se puede mostrar un archivo binario",
|
||||
"instanceShell.gitChanges.sections.staged": "Cambios preparados",
|
||||
"instanceShell.gitChanges.sections.unstaged": "Cambios",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Agregar al prompt",
|
||||
"instanceShell.gitChanges.actions.stage": "Preparar archivo",
|
||||
"instanceShell.gitChanges.actions.unstage": "Quitar del área preparada",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Escribe el mensaje del commit",
|
||||
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||
"instanceShell.gitChanges.commit.submitting": "Confirmando...",
|
||||
"instanceShell.gitChanges.commit.success": "Commit creado correctamente",
|
||||
"instanceShell.gitChanges.commit.error": "No se pudo crear el commit",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
||||
|
||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
|
||||
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
|
||||
"instanceShell.gitChanges.deleted": "Supprimé",
|
||||
"instanceShell.gitChanges.binaryViewer": "Impossible d'afficher un fichier binaire",
|
||||
"instanceShell.gitChanges.sections.staged": "Changements indexés",
|
||||
"instanceShell.gitChanges.sections.unstaged": "Changements",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Ajouter au prompt",
|
||||
"instanceShell.gitChanges.actions.stage": "Indexer le fichier",
|
||||
"instanceShell.gitChanges.actions.unstage": "Retirer de l'index",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Saisissez le message du commit",
|
||||
"instanceShell.gitChanges.commit.submit": "Valider",
|
||||
"instanceShell.gitChanges.commit.submitting": "Validation...",
|
||||
"instanceShell.gitChanges.commit.success": "Commit créé avec succès",
|
||||
"instanceShell.gitChanges.commit.error": "Impossible de créer le commit",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
||||
|
||||
@@ -138,6 +138,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
|
||||
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
|
||||
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",
|
||||
"instanceShell.gitChanges.binaryViewer": "לא ניתן להציג קובץ בינארי",
|
||||
"instanceShell.gitChanges.sections.staged": "שינויים שנשמרו ל-staging",
|
||||
"instanceShell.gitChanges.sections.unstaged": "שינויים",
|
||||
"instanceShell.gitChanges.actions.insertContext": "הוסף לפרומפט",
|
||||
"instanceShell.gitChanges.actions.stage": "העבר ל-staging",
|
||||
"instanceShell.gitChanges.actions.unstage": "הוצא מ-staging",
|
||||
"instanceShell.gitChanges.commit.placeholder": "הזן הודעת commit",
|
||||
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||
"instanceShell.gitChanges.commit.submitting": "מבצע commit...",
|
||||
"instanceShell.gitChanges.commit.success": "ה-commit נוצר בהצלחה",
|
||||
"instanceShell.gitChanges.commit.error": "יצירת ה-commit נכשלה",
|
||||
"instanceShell.diff.hideUnchanged": "הסתר אזורים ללא שינוי",
|
||||
"instanceShell.diff.showFull": "הצג קובץ מלא",
|
||||
"instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת",
|
||||
|
||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
|
||||
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
|
||||
"instanceShell.gitChanges.deleted": "削除済み",
|
||||
"instanceShell.gitChanges.binaryViewer": "バイナリファイルは表示できません",
|
||||
"instanceShell.gitChanges.sections.staged": "ステージ済みの変更",
|
||||
"instanceShell.gitChanges.sections.unstaged": "変更",
|
||||
"instanceShell.gitChanges.actions.insertContext": "プロンプトに追加",
|
||||
"instanceShell.gitChanges.actions.stage": "ファイルをステージ",
|
||||
"instanceShell.gitChanges.actions.unstage": "ステージ解除",
|
||||
"instanceShell.gitChanges.commit.placeholder": "コミットメッセージを入力",
|
||||
"instanceShell.gitChanges.commit.submit": "コミット",
|
||||
"instanceShell.gitChanges.commit.submitting": "コミット中...",
|
||||
"instanceShell.gitChanges.commit.success": "コミットを作成しました",
|
||||
"instanceShell.gitChanges.commit.error": "コミットを作成できませんでした",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "ファイル一覧",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
|
||||
|
||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
|
||||
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
|
||||
"instanceShell.gitChanges.deleted": "Удалено",
|
||||
"instanceShell.gitChanges.binaryViewer": "Невозможно показать бинарный файл",
|
||||
"instanceShell.gitChanges.sections.staged": "Подготовленные изменения",
|
||||
"instanceShell.gitChanges.sections.unstaged": "Изменения",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Добавить в промпт",
|
||||
"instanceShell.gitChanges.actions.stage": "Подготовить файл",
|
||||
"instanceShell.gitChanges.actions.unstage": "Убрать из staging",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Введите сообщение коммита",
|
||||
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||
"instanceShell.gitChanges.commit.submitting": "Создание commit...",
|
||||
"instanceShell.gitChanges.commit.success": "Commit успешно создан",
|
||||
"instanceShell.gitChanges.commit.error": "Не удалось создать commit",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "Список файлов",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
|
||||
|
||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
|
||||
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
|
||||
"instanceShell.gitChanges.deleted": "已删除",
|
||||
"instanceShell.gitChanges.binaryViewer": "无法显示二进制文件",
|
||||
"instanceShell.gitChanges.sections.staged": "已暂存的更改",
|
||||
"instanceShell.gitChanges.sections.unstaged": "更改",
|
||||
"instanceShell.gitChanges.actions.insertContext": "添加到提示词",
|
||||
"instanceShell.gitChanges.actions.stage": "暂存文件",
|
||||
"instanceShell.gitChanges.actions.unstage": "取消暂存",
|
||||
"instanceShell.gitChanges.commit.placeholder": "输入提交信息",
|
||||
"instanceShell.gitChanges.commit.submit": "提交",
|
||||
"instanceShell.gitChanges.commit.submitting": "正在提交...",
|
||||
"instanceShell.gitChanges.commit.success": "提交已成功创建",
|
||||
"instanceShell.gitChanges.commit.error": "无法创建提交",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "文件列表",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
|
||||
|
||||
Reference in New Issue
Block a user