Restore copy actions in web diffs (#4229)

* fix(app): restore diff copy actions on web

The canvas-backed diff owns selection without creating a DOM range, so browser context menus cannot expose copy actions themselves. Route keyboard and context-menu actions through the renderer's canonical source selection instead.

* fix(app): harden web diff copy actions

* fix(app): default split header selection to new pane

* fix(app): isolate nested diff context menus

* fix(app): select available split diff pane
This commit is contained in:
Mohamed Boudra
2026-09-02 20:25:55 +02:00
committed by GitHub
parent 53d824a320
commit 2d8ff6f1cf
13 changed files with 387 additions and 21 deletions
+76 -1
View File
@@ -1019,6 +1019,16 @@ test("autofocusing an inline review keeps the Changes tab focused", async ({ pag
.toBe(focusedBackground);
});
test("inline reviews keep the browser text context menu", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await startReviewOnFirstChangedLine(page);
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
});
test("split canvas creates a review on the changed side and keeps it in that column", async ({
page,
}) => {
@@ -1142,6 +1152,33 @@ test("canvas diff copies a dragged character selection without opening a review"
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff context menu copies selections and source lines", async ({ context, page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-selection").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-line").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ");
});
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
const workspace = await createWorkspaceWithTwoSelectionDiffs();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }, 1);
await page.getByTestId("diff-file-0-toggle").click();
await rightClickFirstChangedLine(page, 1);
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
});
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await useUnwrappedDiffLines(page);
@@ -1539,6 +1576,29 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-canvas-selection-shift-", {
files: [
{ path: "src/first.ts", content: "" },
{ path: "src/second.ts", content: "" },
],
});
await Promise.all([
writeFile(path.join(repo.path, "src/first.ts"), "FIRST\n"),
writeFile(path.join(repo.path, "src/second.ts"), "ABCDEFGHIJ\n"),
]);
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create selection workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
const files = Array.from({ length: fileCount }, (_, index) => ({
path: `src/file-${String(index).padStart(4, "0")}.ts`,
@@ -1753,6 +1813,20 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
}
async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise<void> {
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
const canvas = page.getByTestId("git-diff-canvas");
const [bodyBounds, fontSize] = await Promise.all([
body.boundingBox(),
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
]);
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5);
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5, {
button: "right",
});
}
async function hoverFirstChangedGutter(page: Page): Promise<void> {
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
@@ -1793,8 +1867,9 @@ async function deleteInlineReview(page: Page): Promise<void> {
async function dragExactAddedText(
page: Page,
offsets: { startOffset: number; endOffset: number },
fileIndex = 0,
): Promise<void> {
const body = page.getByTestId("diff-file-0-body");
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
const canvas = page.getByTestId("git-diff-canvas");
const [bodyBounds, metrics] = await Promise.all([
body.boundingBox(),
@@ -4,6 +4,8 @@ import {
characterOffsetAtPoint,
hitTestDiffDocument,
orderedSelection,
selectAllSource,
selectCellSource,
selectedSourceText,
} from "./hit-testing";
import { buildDiffDocumentModel } from "./model";
@@ -104,6 +106,64 @@ describe("diff hit testing", () => {
}),
).toBeNull();
});
it("selects one source line for context-menu copy", () => {
const model = buildModel("copy this", { wrapLines: false });
const cell = changedCell(model);
expect(selectedSourceText(model, selectCellSource(model, position(model, cell, 4)))).toBe(
"copy this",
);
});
it("selects all diff source in document order", () => {
const model = buildModel("first", { wrapLines: false });
expect(selectedSourceText(model, selectAllSource(model)!)).toBe("@@ -1,0 +1,2 @@\nfirst\ntail");
});
it("selects all source from one split side without interleaving panes", () => {
const model = buildModel("unused", { files: [splitFile()], layout: "split" });
const changedRow = model.rows.find(
(row) => row.kind === "line" && row.cells.length === 2 && row.cells.every(Boolean),
);
expect(changedRow?.kind).toBe("line");
const left = changedRow!.kind === "line" ? changedRow!.cells[0]! : null;
const right = changedRow!.kind === "line" ? changedRow!.cells[1]! : null;
expect(selectedSourceText(model, selectAllSource(model, position(model, left!, 0))!)).toBe(
"@@ -1,2 +1,2 @@\nold-one\nold-two",
);
expect(selectedSourceText(model, selectAllSource(model, position(model, right!, 0))!)).toBe(
"new-one\nnew-two",
);
});
it("defaults split hunk-header select-all to the new pane", () => {
const model = buildModel("unused", { files: [splitFile()], layout: "split" });
const header = model.rows
.filter((row) => row.kind === "line")
.flatMap((row) => row.cells)
.find((cell) => cell?.type === "header");
expect(header).toBeDefined();
expect(selectedSourceText(model, selectAllSource(model, position(model, header!, 0))!)).toBe(
"new-one\nnew-two",
);
});
it("falls back to the old pane for a deletion-only split hunk", () => {
const model = buildModel("unused", { files: [deletedSplitFile()], layout: "split" });
const header = model.rows
.filter((row) => row.kind === "line")
.flatMap((row) => row.cells)
.find((cell) => cell?.type === "header");
expect(header).toBeDefined();
expect(selectedSourceText(model, selectAllSource(model, position(model, header!, 0))!)).toBe(
"@@ -1,2 +1,0 @@\nold-one\nold-two",
);
});
});
function buildModel(changedContent: string, overrides: Partial<BuildDiffDocumentModelInput> = {}) {
@@ -212,6 +272,29 @@ function splitFile(): ParsedDiffFile {
};
}
function deletedSplitFile(): ParsedDiffFile {
return {
path: "src/deleted.ts",
isNew: false,
isDeleted: true,
additions: 0,
deletions: 2,
hunks: [
{
oldStart: 1,
oldCount: 2,
newStart: 1,
newCount: 0,
lines: [
{ type: "header", content: "@@ -1,2 +1,0 @@" },
{ type: "remove", content: "old-one" },
{ type: "remove", content: "old-two" },
],
},
],
};
}
function reviewActionsForFirstAddition(): NonNullable<
BuildDiffDocumentModelInput["reviewActions"]
> {
@@ -105,6 +105,64 @@ export function selectedSourceText(model: DiffDocumentModel, selection: DiffSele
.join("\n");
}
export function selectCellSource(
model: DiffDocumentModel,
position: DiffCharacterPosition,
): DiffSelection {
const row = model.rows[position.rowIndex];
const cell = row?.kind === "line" ? row.cells[position.cellIndex] : null;
const end = cell?.content.length ?? position.sourceOffset;
return {
anchor: { ...position, sourceOffset: 0 },
focus: { ...position, sourceOffset: end },
};
}
export function selectAllSource(
model: DiffDocumentModel,
preferredPosition?: DiffCharacterPosition,
): DiffSelection | null {
let first: DiffCharacterPosition | null = null;
let last: DiffCharacterPosition | null = null;
const preferredRow = preferredPosition ? model.rows[preferredPosition.rowIndex] : null;
const preferredCell =
preferredPosition && preferredRow?.kind === "line"
? preferredRow.cells[preferredPosition.cellIndex]
: null;
let splitCellIndex: number | null = null;
if (model.layout === "split") {
const hasNewSource = model.rows.some(
(row) => row.kind === "line" && row.cells[1] && row.cells[1].type !== "header",
);
splitCellIndex = hasNewSource ? 1 : 0;
if (preferredPosition && preferredCell && preferredCell.type !== "header") {
splitCellIndex = preferredPosition.cellIndex;
}
}
for (const row of model.rows) {
if (row.kind !== "line") continue;
row.cells.forEach((cell, cellIndex) => {
if (!cell || (splitCellIndex !== null && cellIndex !== splitCellIndex)) return;
const side = cell.reviewTarget?.side ?? (cellIndex === 0 ? "old" : "new");
first ??= {
fileIndex: row.fileIndex,
rowIndex: row.index,
cellIndex,
side,
sourceOffset: 0,
};
last = {
fileIndex: row.fileIndex,
rowIndex: row.index,
cellIndex,
side,
sourceOffset: cell.content.length,
};
});
}
return first && last ? { anchor: first, focus: last } : null;
}
function comparePosition(left: DiffCharacterPosition, right: DiffCharacterPosition): number {
return (
left.rowIndex - right.rowIndex ||
@@ -2,7 +2,16 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { useTranslation } from "react-i18next";
import type { ViewStyle } from "react-native";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useToast } from "@/contexts/toast-context";
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
import { copyToClipboard } from "@/utils/copy-to-clipboard";
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
import { DocumentFileHeader } from "./document-file-header";
import {
@@ -11,7 +20,12 @@ import {
diffMaterializationWindow,
resolveVisibleFileSections,
} from "./header-layout";
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
import {
hitTestDiffDocument,
selectAllSource,
selectCellSource,
selectedSourceText,
} from "./hit-testing";
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
import { HorizontalScroll } from "./horizontal-scroll.web";
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
@@ -33,6 +47,7 @@ const RESIZE_SETTLE_DELAY_MS = 120;
export function DiffSurface(props: DiffSurfaceProps) {
const { t } = useTranslation();
const toast = useToast();
const workspaceCache = useDiffDocumentWorkspaceCache();
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -75,6 +90,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
const [interactionFiles, setInteractionFiles] = useState<
ReturnType<typeof buildDiffDocumentModel>["files"]
>([]);
const [hasSelection, setHasSelection] = useState(false);
const [contextHit, setContextHit] = useState<Extract<DiffHit, { kind: "cell" }> | null>(null);
const [hoveredAffordance, setHoveredAffordance] = useState<{
hit: Extract<DiffHit, { kind: "cell" }>;
left: number;
@@ -488,13 +505,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
},
[schedulePaint],
);
const pointHit = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const pointHitAt = useCallback((clientX: number, clientY: number) => {
const currentModel = modelRef.current;
const root = rootRef.current;
if (!currentModel || !root) return null;
const bounds = root.getBoundingClientRect();
const x = event.clientX - bounds.left;
const documentY = event.clientY - bounds.top + scrollTopRef.current;
const x = clientX - bounds.left;
const documentY = clientY - bounds.top + scrollTopRef.current;
const file = currentModel.files.find(
(entry) => entry.top <= documentY && documentY < entry.bottom,
);
@@ -505,6 +522,27 @@ export function DiffSurface(props: DiffSurfaceProps) {
horizontalOffset: file ? (horizontalOffsetsRef.current.get(file.path) ?? 0) : 0,
});
}, []);
const pointHit = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => pointHitAt(event.clientX, event.clientY),
[pointHitAt],
);
const setSelection = useCallback(
(selection: DiffSelection | null) => {
selectionRef.current = selection;
const currentModel = modelRef.current;
setHasSelection(
Boolean(
selection && currentModel && selectedSourceText(currentModel, selection).length > 0,
),
);
schedulePaint();
},
[schedulePaint],
);
useEffect(() => {
dragRef.current = null;
setSelection(null);
}, [props.collapsedFilePaths, props.displayPreferences.layout, props.files, setSelection]);
const pointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
@@ -518,8 +556,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
return;
const dismissSelectionOnClick = selectionRef.current !== null;
if (dismissSelectionOnClick) {
selectionRef.current = null;
schedulePaint();
setSelection(null);
}
const hit = pointHit(event);
if (hit?.kind !== "cell") return;
@@ -530,12 +567,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
moved: false,
dismissSelectionOnClick,
};
selectionRef.current = { anchor: hit.position, focus: hit.position };
setSelection({ anchor: hit.position, focus: hit.position });
event.currentTarget.focus();
event.currentTarget.setPointerCapture(event.pointerId);
schedulePaint();
},
[pointHit, schedulePaint],
[pointHit, setSelection],
);
const updateActiveHeader = useCallback(
(target: EventTarget | null) => {
@@ -589,10 +625,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
setHoveredAffordance(null);
}
if (!drag || hit?.kind !== "cell") return;
selectionRef.current = { anchor: drag.anchor, focus: hit.position };
schedulePaint();
setSelection({ anchor: drag.anchor, focus: hit.position });
},
[pointHit, schedulePaint, updateActiveHeader, viewport.width],
[pointHit, setSelection, updateActiveHeader, viewport.width],
);
const pointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
@@ -612,8 +647,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
})
: false;
if (drag && !moved && drag.dismissSelectionOnClick) {
selectionRef.current = null;
schedulePaint();
setSelection(null);
return;
}
if (
@@ -624,12 +658,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
hit.target &&
reviewActions
) {
selectionRef.current = null;
setSelection(null);
reviewActions.onStartComment(hit.target);
schedulePaint();
}
},
[pointHit, reviewActions, schedulePaint],
[pointHit, reviewActions, setSelection],
);
const cancelPointer = useCallback(() => {
dragRef.current = null;
@@ -643,6 +676,61 @@ export function DiffSurface(props: DiffSurfaceProps) {
},
[model],
);
const prepareContextMenu = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const hit = pointHitAt(event.clientX, event.clientY);
setContextHit(hit?.kind === "cell" ? hit : null);
},
[pointHitAt],
);
const writeSourceText = useCallback(
(text: string) => {
void copyToClipboard(text)
.then(() => toast.copied(t("common.states.copied")))
.catch(() => toast.error(t("common.errors.unableToCopy")));
},
[t, toast],
);
const copySelectedSource = useCallback(() => {
const currentModel = modelRef.current;
const selection = selectionRef.current;
if (currentModel && selection) writeSourceText(selectedSourceText(currentModel, selection));
}, [writeSourceText]);
const copyContextLine = useCallback(() => {
const currentModel = modelRef.current;
if (!currentModel || !contextHit) return;
const selection = selectCellSource(currentModel, contextHit.position);
writeSourceText(selectedSourceText(currentModel, selection));
}, [contextHit, writeSourceText]);
const selectAll = useCallback(() => {
const currentModel = modelRef.current;
if (currentModel) {
setSelection(
selectAllSource(currentModel, contextHit?.position ?? selectionRef.current?.focus),
);
}
}, [contextHit?.position, setSelection]);
const keyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const target = event.target;
if (
target instanceof Element &&
target.closest('input, textarea, [contenteditable="true"]')
) {
return;
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") {
event.preventDefault();
selectAll();
return;
}
if (event.key === "Escape" && selectionRef.current) {
event.preventDefault();
setSelection(null);
}
},
[selectAll, setSelection],
);
const rootStyle = useMemo<React.CSSProperties>(
() => ({ ...ROOT_STYLE, background: props.palette.surface }),
[props.palette.surface],
@@ -672,7 +760,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
[desiredTypography, loadedTypography],
);
return (
const surface = (
<div data-testid="git-diff-canvas-root" ref={rootRef} style={rootStyle}>
<div
ref={scrollRef}
@@ -685,7 +773,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
onPointerCancel={cancelPointer}
onLostPointerCapture={cancelPointer}
onPointerLeave={pointerLeave}
onContextMenu={prepareContextMenu}
onCopy={copy}
onKeyDown={keyDown}
style={SCROLL_STYLE}
>
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
@@ -752,6 +842,34 @@ export function DiffSurface(props: DiffSurfaceProps) {
) : null}
</div>
);
return (
<ContextMenu>
<ContextMenuTrigger contextOnly style={CONTEXT_TRIGGER_STYLE}>
{surface}
</ContextMenuTrigger>
<ContextMenuContent align="start" minWidth={180} testID="diff-source-context-menu">
<ContextMenuItem
disabled={!hasSelection}
onSelect={copySelectedSource}
testID="diff-source-copy-selection"
>
{t("common.actions.copy")}
</ContextMenuItem>
<ContextMenuItem
disabled={!contextHit}
onSelect={copyContextLine}
testID="diff-source-copy-line"
>
{t("common.actions.copyLine")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={selectAll} testID="diff-source-select-all">
{t("common.actions.selectAll")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
function shiftCanvasPixels(input: {
@@ -820,7 +938,7 @@ function WebFileHeaderSection({
[file.bottom, file.top],
);
return (
<div style={style}>
<div style={style} onContextMenu={stopContextMenuPropagation}>
<div
data-diff-header="true"
data-diff-header-path={file.path}
@@ -864,7 +982,7 @@ function WebReviewThread({
[height, left, top, width],
);
return (
<div style={style} data-diff-review="true">
<div style={style} data-diff-review="true" onContextMenu={stopContextMenuPropagation}>
<InlineReviewThread
reviewTarget={target}
reviewActions={actions}
@@ -876,12 +994,17 @@ function WebReviewThread({
);
}
function stopContextMenuPropagation(event: React.MouseEvent): void {
event.stopPropagation();
}
const ROOT_STYLE: React.CSSProperties = {
position: "relative",
flex: 1,
minHeight: 0,
overflow: "hidden",
};
const CONTEXT_TRIGGER_STYLE: ViewStyle = { flex: 1, minHeight: 0 };
const SCROLL_STYLE: React.CSSProperties = {
position: "absolute",
inset: 0,
+3
View File
@@ -10,10 +10,12 @@ export const ar: TranslationResources = {
cancel: "يلغي",
close: "يغلق",
copy: "ينسخ",
copyLine: "نسخ السطر",
dismiss: "رفض",
retry: "أعد المحاولة",
search: "يبحث",
select: "يختار",
selectAll: "تحديد الكل",
},
placeholders: {
search: "يبحث...",
@@ -33,6 +35,7 @@ export const ar: TranslationResources = {
errors: {
error: "خطأ",
unableToSave: "غير قادر على الحفظ",
unableToCopy: "تعذر النسخ",
nameRequired: "الاسم مطلوب",
daemonUnavailable: "Daemon غير متوفر",
daemonClientUnavailable: "عميل Daemon غير متوفر",
+3
View File
@@ -7,10 +7,12 @@ export const en = {
cancel: "Cancel",
close: "Close",
copy: "Copy",
copyLine: "Copy line",
dismiss: "Dismiss",
retry: "Retry",
search: "Search",
select: "Select",
selectAll: "Select all",
},
placeholders: {
search: "Search...",
@@ -30,6 +32,7 @@ export const en = {
errors: {
error: "Error",
unableToSave: "Unable to save",
unableToCopy: "Unable to copy",
nameRequired: "Name is required",
daemonUnavailable: "Daemon unavailable",
daemonClientUnavailable: "Daemon client unavailable",
+3
View File
@@ -10,10 +10,12 @@ export const es: TranslationResources = {
cancel: "Cancelar",
close: "Cerrar",
copy: "Copiar",
copyLine: "Copiar línea",
dismiss: "Despedir",
retry: "Rever",
search: "Buscar",
select: "Seleccionar",
selectAll: "Seleccionar todo",
},
placeholders: {
search: "Buscar...",
@@ -33,6 +35,7 @@ export const es: TranslationResources = {
errors: {
error: "Error",
unableToSave: "No se puede guardar",
unableToCopy: "No se pudo copiar",
nameRequired: "El nombre es obligatorio",
daemonUnavailable: "Daemonno disponible",
daemonClientUnavailable: "ClienteDaemonno disponible",
+3
View File
@@ -10,10 +10,12 @@ export const fr: TranslationResources = {
cancel: "Annuler",
close: "Fermer",
copy: "Copie",
copyLine: "Copier la ligne",
dismiss: "Rejeter",
retry: "Réessayer",
search: "Recherche",
select: "Sélectionner",
selectAll: "Tout sélectionner",
},
placeholders: {
search: "Recherche...",
@@ -33,6 +35,7 @@ export const fr: TranslationResources = {
errors: {
error: "Erreur",
unableToSave: "Impossible d'enregistrer",
unableToCopy: "Impossible de copier",
nameRequired: "Le nom est requis",
daemonUnavailable: "Daemonindisponible",
daemonClientUnavailable: "ClientDaemonindisponible",
+3
View File
@@ -10,10 +10,12 @@ export const ja: TranslationResources = {
cancel: "キャンセル",
close: "閉じる",
copy: "コピー",
copyLine: "行をコピー",
dismiss: "閉じる",
retry: "再試行",
search: "検索",
select: "選択",
selectAll: "すべて選択",
},
placeholders: {
search: "検索...",
@@ -33,6 +35,7 @@ export const ja: TranslationResources = {
errors: {
error: "エラー",
unableToSave: "保存できません",
unableToCopy: "コピーできません",
nameRequired: "名前は必須です",
daemonUnavailable: "デーモンが利用できません",
daemonClientUnavailable: "デーモンクライアントが利用できません",
+3
View File
@@ -10,10 +10,12 @@ export const ko: TranslationResources = {
cancel: "취소",
close: "닫기",
copy: "복사",
copyLine: "줄 복사",
dismiss: "닫기",
retry: "다시 시도",
search: "검색",
select: "선택",
selectAll: "모두 선택",
},
placeholders: {
search: "검색...",
@@ -33,6 +35,7 @@ export const ko: TranslationResources = {
errors: {
error: "오류",
unableToSave: "저장할 수 없습니다",
unableToCopy: "복사할 수 없습니다",
nameRequired: "이름을 입력하세요",
daemonUnavailable: "데몬을 사용할 수 없습니다",
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
+3
View File
@@ -10,10 +10,12 @@ export const ptBR: TranslationResources = {
cancel: "Cancelar",
close: "Fechar",
copy: "Copiar",
copyLine: "Copiar linha",
dismiss: "Dispensar",
retry: "Tentar novamente",
search: "Buscar",
select: "Selecionar",
selectAll: "Selecionar tudo",
},
placeholders: {
search: "Buscar...",
@@ -33,6 +35,7 @@ export const ptBR: TranslationResources = {
errors: {
error: "Erro",
unableToSave: "Não foi possível salvar",
unableToCopy: "Não foi possível copiar",
nameRequired: "O nome é obrigatório",
daemonUnavailable: "Daemon indisponível",
daemonClientUnavailable: "Cliente do daemon indisponível",
+3
View File
@@ -10,10 +10,12 @@ export const ru: TranslationResources = {
cancel: "Отмена",
close: "Закрыть",
copy: "Копировать",
copyLine: "Копировать строку",
dismiss: "Отклонить",
retry: "Повторить",
search: "Поиск",
select: "Выбрать",
selectAll: "Выбрать все",
},
placeholders: {
search: "Поиск...",
@@ -33,6 +35,7 @@ export const ru: TranslationResources = {
errors: {
error: "Ошибка",
unableToSave: "Не удалось сохранить",
unableToCopy: "Не удалось скопировать",
nameRequired: "Требуется имя",
daemonUnavailable: "Daemon недоступен",
daemonClientUnavailable: "Daemon клиента недоступен",
+3
View File
@@ -10,10 +10,12 @@ export const zhCN: TranslationResources = {
cancel: "取消",
close: "关闭",
copy: "复制",
copyLine: "复制行",
dismiss: "关闭",
retry: "重试",
search: "搜索",
select: "选择",
selectAll: "全选",
},
placeholders: {
search: "搜索...",
@@ -33,6 +35,7 @@ export const zhCN: TranslationResources = {
errors: {
error: "错误",
unableToSave: "无法保存",
unableToCopy: "无法复制",
nameRequired: "名称必填",
daemonUnavailable: "Daemon 不可用",
daemonClientUnavailable: "Daemon client 不可用",