fix(app): stabilize diff reviews and workspace tabs
Reuse measured diff geometry when review controls open, keeping the interaction responsive on large changes. Preserve complete tab-track snapshots while exact content widths settle so new tabs and retained workspaces do not flash or truncate.
This commit is contained in:
@@ -25,6 +25,39 @@ const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 1.1;
|
|||||||
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1;
|
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1;
|
||||||
|
|
||||||
diffPerfDescribe("Diff canvas performance", () => {
|
diffPerfDescribe("Diff canvas performance", () => {
|
||||||
|
test("opening a review stays responsive in a large diff", async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const repo = await createLargeDiffRepository();
|
||||||
|
const client = await connectSeedClient();
|
||||||
|
let cdp: CDPSession | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await client.createWorkspace({
|
||||||
|
source: { kind: "directory", path: repo.path },
|
||||||
|
});
|
||||||
|
if (!created.workspace) {
|
||||||
|
throw new Error(created.error ?? "Failed to create diff performance workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
await configureUnwrappedUnifiedDiff(page);
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await openChangesPanel(page);
|
||||||
|
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
cdp = await page.context().newCDPSession(page);
|
||||||
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
||||||
|
const elapsedMs = await openFirstReviewEditor(page);
|
||||||
|
expect(elapsedMs).toBeLessThanOrEqual(200 * CPU_SLOWDOWN);
|
||||||
|
} finally {
|
||||||
|
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
|
||||||
|
await cdp?.detach().catch(() => undefined);
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await repo.cleanup().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("one canvas stays filled through continuous fast scrolling", async ({ page }, testInfo) => {
|
test("one canvas stays filled through continuous fast scrolling", async ({ page }, testInfo) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
const repo = await createLargeDiffRepository();
|
const repo = await createLargeDiffRepository();
|
||||||
@@ -241,6 +274,21 @@ async function configureUnwrappedUnifiedDiff(page: Page): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openFirstReviewEditor(page: Page): Promise<number> {
|
||||||
|
const body = page.getByTestId("diff-file-0-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);
|
||||||
|
const startedAt = performance.now();
|
||||||
|
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
|
||||||
|
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||||
|
return performance.now() - startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
async function expectSingleCanvasSurface(page: Page): Promise<void> {
|
async function expectSingleCanvasSurface(page: Page): Promise<void> {
|
||||||
await expect(page.locator("canvas")).toHaveCount(1);
|
await expect(page.locator("canvas")).toHaveCount(1);
|
||||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
waitForTabWithTitle,
|
waitForTabWithTitle,
|
||||||
measureTileTransition,
|
measureTileTransition,
|
||||||
sampleTabsDuringTransition,
|
sampleTabsDuringTransition,
|
||||||
|
expectTabTitleFits,
|
||||||
terminalSurfaceLocator,
|
terminalSurfaceLocator,
|
||||||
} from "../support/helpers/launcher";
|
} from "../support/helpers/launcher";
|
||||||
import { expectComposerVisible, composerLocator } from "../support/helpers/composer";
|
import { expectComposerVisible, composerLocator } from "../support/helpers/composer";
|
||||||
@@ -197,7 +198,7 @@ test.describe("Tab transitions (no flash)", () => {
|
|||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
// Sample tabs at high frequency across the transition
|
// Sample tabs at high frequency across the transition
|
||||||
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
|
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
|
||||||
|
|
||||||
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||||
for (const snapshot of snapshots) {
|
for (const snapshot of snapshots) {
|
||||||
@@ -207,11 +208,15 @@ test.describe("Tab transitions (no flash)", () => {
|
|||||||
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
|
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
|
||||||
// When running in-suite, previous tests may have created tabs on the shared workspace,
|
// When running in-suite, previous tests may have created tabs on the shared workspace,
|
||||||
// so we allow +2 tolerance for accumulated state and React render batching.
|
// so we allow +2 tolerance for accumulated state and React render batching.
|
||||||
const counts = snapshots.map((s) => s.length);
|
const counts = snapshots.map((snapshot) => snapshot.length);
|
||||||
const maxCount = Math.max(...counts);
|
const maxCount = Math.max(...counts);
|
||||||
const initialCount = counts[0] ?? 0;
|
const initialCount = counts[0] ?? 0;
|
||||||
|
|
||||||
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
|
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
|
||||||
|
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||||
|
|||||||
@@ -158,21 +158,59 @@ export async function sampleTabsDuringTransition(
|
|||||||
page: Page,
|
page: Page,
|
||||||
action: () => Promise<void>,
|
action: () => Promise<void>,
|
||||||
durationMs = 2_000,
|
durationMs = 2_000,
|
||||||
intervalMs = 30,
|
): Promise<Array<Array<{ id: string; width: number }>>> {
|
||||||
): Promise<string[][]> {
|
await page.evaluate((duration) => {
|
||||||
const snapshots: string[][] = [];
|
const scope = globalThis as typeof globalThis & {
|
||||||
const startSampling = async () => {
|
__paseoTabTrackFrames?: Array<Array<{ id: string; width: number }>>;
|
||||||
const start = Date.now();
|
};
|
||||||
while (Date.now() - start < durationMs) {
|
scope.__paseoTabTrackFrames = [];
|
||||||
snapshots.push(await getTabTestIds(page));
|
const startedAt = performance.now();
|
||||||
await page.waitForTimeout(intervalMs);
|
function sample() {
|
||||||
|
const tabs = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>(
|
||||||
|
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
|
||||||
|
),
|
||||||
|
).filter((element) => element.getClientRects().length > 0);
|
||||||
|
scope.__paseoTabTrackFrames?.push(
|
||||||
|
tabs.map((element) => ({
|
||||||
|
id: element.getAttribute("data-testid") ?? "",
|
||||||
|
width: Math.round(element.getBoundingClientRect().width),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if (performance.now() - startedAt < duration) {
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
requestAnimationFrame(sample);
|
||||||
|
}, durationMs);
|
||||||
const samplingPromise = startSampling();
|
|
||||||
await action();
|
await action();
|
||||||
await samplingPromise;
|
await page.waitForTimeout(durationMs + 100);
|
||||||
return snapshots;
|
return page.evaluate(() => {
|
||||||
|
const scope = globalThis as typeof globalThis & {
|
||||||
|
__paseoTabTrackFrames?: Array<Array<{ id: string; width: number }>>;
|
||||||
|
};
|
||||||
|
return scope.__paseoTabTrackFrames ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectTabTitleFits(
|
||||||
|
page: Page,
|
||||||
|
title: string,
|
||||||
|
widthRange: { min: number; max: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const tab = page
|
||||||
|
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||||
|
.filter({ hasText: title })
|
||||||
|
.filter({ visible: true })
|
||||||
|
.last();
|
||||||
|
await expect(tab).toContainText(title);
|
||||||
|
const tabWidth = await tab.evaluate((element) => element.getBoundingClientRect().width);
|
||||||
|
expect(tabWidth).toBeGreaterThanOrEqual(widthRange.min);
|
||||||
|
expect(tabWidth).toBeLessThanOrEqual(widthRange.max);
|
||||||
|
const label = tab.getByText(title, { exact: true });
|
||||||
|
await expect(label).toBeVisible();
|
||||||
|
const labelFits = await label.evaluate((element) => element.scrollWidth <= element.clientWidth);
|
||||||
|
expect(labelFits).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function terminalSurfaceLocator(page: Page) {
|
export function terminalSurfaceLocator(page: Page) {
|
||||||
|
|||||||
@@ -272,6 +272,54 @@ describe("diff document model", () => {
|
|||||||
expect(reviewRow?.kind === "line" ? reviewRow.reviewHeight : 0).toBe(148);
|
expect(reviewRow?.kind === "line" ? reviewRow.reviewHeight : 0).toBe(148);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reflows review geometry without remeasuring unchanged text", () => {
|
||||||
|
const files = [file("a.ts"), file("b.ts")];
|
||||||
|
let measurementCount = 0;
|
||||||
|
const countingMeasurer: TextMeasurer = {
|
||||||
|
measure(text) {
|
||||||
|
measurementCount += 1;
|
||||||
|
return Array.from(text).length * 10;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const base = buildDiffDocumentModel(input({ files, measureText: countingMeasurer }));
|
||||||
|
const initialMeasurementCount = measurementCount;
|
||||||
|
const reviewCell = base.rows
|
||||||
|
.filter((row) => row.kind === "line")
|
||||||
|
.flatMap((row) => row.cells)
|
||||||
|
.find((cell) => cell?.reviewTarget);
|
||||||
|
if (!reviewCell?.reviewTarget) throw new Error("Expected a reviewable cell");
|
||||||
|
const baseReviewRow = rowForCell(base, reviewCell);
|
||||||
|
const baseSecondFileTop = base.files[1]!.top;
|
||||||
|
|
||||||
|
const withEditor = buildDiffDocumentModel(
|
||||||
|
input({
|
||||||
|
files,
|
||||||
|
measureText: countingMeasurer,
|
||||||
|
reviewActions: reviewActionsWithEditor(reviewCell.reviewTarget),
|
||||||
|
reuseFrom: [base],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const editorRow = rowForReviewTarget(withEditor, reviewCell.reviewTarget.key);
|
||||||
|
|
||||||
|
expect(measurementCount).toBe(initialMeasurementCount);
|
||||||
|
expect(editorRow.reviewHeight).toBe(148);
|
||||||
|
expect(editorRow.cells).toBe(baseReviewRow.cells);
|
||||||
|
expect(withEditor.files[1]!.top).toBe(baseSecondFileTop + 148);
|
||||||
|
|
||||||
|
const withoutEditor = buildDiffDocumentModel(
|
||||||
|
input({
|
||||||
|
files,
|
||||||
|
measureText: countingMeasurer,
|
||||||
|
reviewActions: reviewActionsWithEditor(null),
|
||||||
|
reuseFrom: [base, withEditor],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(measurementCount).toBe(initialMeasurementCount);
|
||||||
|
expect(withoutEditor.height).toBe(base.height);
|
||||||
|
expect(withoutEditor.files[1]!.top).toBe(baseSecondFileTop);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves a logical source anchor through width and font metric relayout", () => {
|
it("preserves a logical source anchor through width and font metric relayout", () => {
|
||||||
const narrow = buildDiffDocumentModel(
|
const narrow = buildDiffDocumentModel(
|
||||||
input({ files: [fileWithChangedLine("abcdefghij")], viewportWidth: 90 }),
|
input({ files: [fileWithChangedLine("abcdefghij")], viewportWidth: 90 }),
|
||||||
@@ -492,3 +540,26 @@ function rowForCell(
|
|||||||
if (!row || row.kind !== "line") throw new Error("Expected the cell row");
|
if (!row || row.kind !== "line") throw new Error("Expected the cell row");
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rowForReviewTarget(model: ReturnType<typeof buildDiffDocumentModel>, key: string) {
|
||||||
|
const row = model.rows.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.kind === "line" && candidate.cells.some((cell) => cell?.reviewTarget?.key === key),
|
||||||
|
);
|
||||||
|
if (!row || row.kind !== "line") throw new Error("Expected the review row");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewActionsWithEditor(
|
||||||
|
target: NonNullable<ReturnType<typeof addedCell>["reviewTarget"]> | null,
|
||||||
|
): NonNullable<BuildDiffDocumentModelInput["reviewActions"]> {
|
||||||
|
return {
|
||||||
|
commentsByTarget: new Map(),
|
||||||
|
editor: target ? { target, body: "", commentId: null } : null,
|
||||||
|
onStartComment() {},
|
||||||
|
onCancelEditor() {},
|
||||||
|
onSaveEditor() {},
|
||||||
|
onEditComment() {},
|
||||||
|
onDeleteComment() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,14 +60,28 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
|
|||||||
if (!isCollapsed && reusableFile) {
|
if (!isCollapsed && reusableFile) {
|
||||||
const reusedRows = reusableModel!.rows.slice(reusableFile.rowStart, reusableFile.rowEnd);
|
const reusedRows = reusableModel!.rows.slice(reusableFile.rowStart, reusableFile.rowEnd);
|
||||||
for (const reusedRow of reusedRows) {
|
for (const reusedRow of reusedRows) {
|
||||||
const height = reusedRow.height;
|
if (reusedRow.kind === "line") {
|
||||||
|
const reviewHeight = reviewHeightForCells(reusedRow.cells, input);
|
||||||
|
const textHeight = reusedRow.height - reusedRow.reviewHeight;
|
||||||
|
const height = textHeight + reviewHeight;
|
||||||
|
rows.push({
|
||||||
|
...reusedRow,
|
||||||
|
index: rows.length,
|
||||||
|
fileIndex,
|
||||||
|
top: documentTop,
|
||||||
|
height,
|
||||||
|
reviewHeight,
|
||||||
|
});
|
||||||
|
documentTop += height;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
rows.push({
|
rows.push({
|
||||||
...reusedRow,
|
...reusedRow,
|
||||||
index: rows.length,
|
index: rows.length,
|
||||||
fileIndex,
|
fileIndex,
|
||||||
top: documentTop,
|
top: documentTop,
|
||||||
});
|
});
|
||||||
documentTop += height;
|
documentTop += reusedRow.height;
|
||||||
}
|
}
|
||||||
maximumHorizontalOverflow = Math.max(0, reusableFile.contentWidth - input.viewportWidth);
|
maximumHorizontalOverflow = Math.max(0, reusableFile.contentWidth - input.viewportWidth);
|
||||||
documentTop += BODY_BORDER_HEIGHT;
|
documentTop += BODY_BORDER_HEIGHT;
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
typography,
|
typography,
|
||||||
measurement,
|
measurement,
|
||||||
props.palette,
|
props.palette,
|
||||||
reviewActions,
|
|
||||||
t,
|
t,
|
||||||
] as const;
|
] as const;
|
||||||
const previous = reusableModelRef.current;
|
const previous = reusableModelRef.current;
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
loadedTypography,
|
loadedTypography,
|
||||||
measurement,
|
measurement,
|
||||||
props.palette,
|
props.palette,
|
||||||
reviewActions,
|
|
||||||
t,
|
t,
|
||||||
] as const;
|
] as const;
|
||||||
const previous = reusableModelRef.current;
|
const previous = reusableModelRef.current;
|
||||||
@@ -210,7 +209,15 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
}, [loadedTypography, measurement, props.palette, viewport.height, viewport.width]);
|
}, [loadedTypography, measurement, props.palette, viewport.height, viewport.width]);
|
||||||
const schedulePaint = useCallback(
|
const schedulePaint = useCallback(
|
||||||
(force = true) => {
|
(force = true) => {
|
||||||
if (force) forcePaintRef.current = true;
|
if (force) {
|
||||||
|
forcePaintRef.current = true;
|
||||||
|
// Fast Refresh preserves refs. A request owned by the previous module can leave a stale
|
||||||
|
// id behind, which would otherwise prevent every subsequent canvas repaint.
|
||||||
|
if (frameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(frameRef.current);
|
||||||
|
frameRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint);
|
if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint);
|
||||||
},
|
},
|
||||||
[paint],
|
[paint],
|
||||||
@@ -260,6 +267,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||||
|
frameRef.current = null;
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -62,6 +63,7 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
|||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||||
|
import { retainWorkspaceTabMeasuredWidth } from "@/screens/workspace/workspace-tab-layout";
|
||||||
import {
|
import {
|
||||||
WorkspaceTabPresentationResolver,
|
WorkspaceTabPresentationResolver,
|
||||||
WorkspaceTabIcon,
|
WorkspaceTabIcon,
|
||||||
@@ -105,9 +107,8 @@ const TAB_ICON_WIDTH = 14;
|
|||||||
const TAB_CONTENT_GAP = 4;
|
const TAB_CONTENT_GAP = 4;
|
||||||
const TAB_DROP_INDICATOR_WIDTH = 4;
|
const TAB_DROP_INDICATOR_WIDTH = 4;
|
||||||
const TAB_MIN_WIDTH = 96;
|
const TAB_MIN_WIDTH = 96;
|
||||||
const TAB_MAX_WIDTH = 240;
|
const TAB_MAX_WIDTH = 160;
|
||||||
const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0;
|
const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0;
|
||||||
const TAB_LABEL_FALLBACK_CHARACTER_WIDTH = 7;
|
|
||||||
const TAB_LABEL_LAYOUT_ALLOWANCE = 4;
|
const TAB_LABEL_LAYOUT_ALLOWANCE = 4;
|
||||||
|
|
||||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||||
@@ -144,9 +145,12 @@ function inlineAddActionButtonStyle({ hovered, pressed }: PressableStateCallback
|
|||||||
return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
|
return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMeasuredWidth(setWidth: Dispatch<SetStateAction<number>>, event: LayoutChangeEvent) {
|
function updateMeasuredWidth(
|
||||||
|
setWidth: React.Dispatch<React.SetStateAction<number>>,
|
||||||
|
event: LayoutChangeEvent,
|
||||||
|
) {
|
||||||
const nextWidth = Math.round(event.nativeEvent.layout.width);
|
const nextWidth = Math.round(event.nativeEvent.layout.width);
|
||||||
setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
|
setWidth((current) => retainWorkspaceTabMeasuredWidth(current, nextWidth));
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabLabelMeasurement({
|
function TabLabelMeasurement({
|
||||||
@@ -392,6 +396,46 @@ interface ResolvedWorkspaceDesktopTabRowItem extends WorkspaceDesktopTabRowItem
|
|||||||
presentation: WorkspaceTabPresentation;
|
presentation: WorkspaceTabPresentation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceTabLabel {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceTabLabelMeasurement {
|
||||||
|
label: string;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceTabTrackSnapshot {
|
||||||
|
signature: string;
|
||||||
|
tabs: ResolvedWorkspaceDesktopTabRowItem[];
|
||||||
|
labels: WorkspaceTabLabel[];
|
||||||
|
labelWidths: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceTabLabelSignature(labels: WorkspaceTabLabel[]): string {
|
||||||
|
return JSON.stringify(labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeWorkspaceTabLabelWidths(
|
||||||
|
labels: WorkspaceTabLabel[],
|
||||||
|
measurements: Map<string, WorkspaceTabLabelMeasurement>,
|
||||||
|
): number[] | null {
|
||||||
|
const widths: number[] = [];
|
||||||
|
for (const { key, label } of labels) {
|
||||||
|
const measurement = measurements.get(key);
|
||||||
|
if (!measurement || measurement.label !== label || measurement.width <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
widths.push(measurement.width + TAB_LABEL_LAYOUT_ALLOWANCE);
|
||||||
|
}
|
||||||
|
return widths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameWidths(left: number[], right: number[]): boolean {
|
||||||
|
return left.length === right.length && left.every((width, index) => width === right[index]);
|
||||||
|
}
|
||||||
|
|
||||||
interface WorkspaceDesktopTabsRowProps {
|
interface WorkspaceDesktopTabsRowProps {
|
||||||
paneId?: string;
|
paneId?: string;
|
||||||
isFocused?: boolean;
|
isFocused?: boolean;
|
||||||
@@ -428,50 +472,49 @@ interface ResolvedWorkspaceDesktopTabsRowProps extends Omit<WorkspaceDesktopTabs
|
|||||||
tabs: ResolvedWorkspaceDesktopTabRowItem[];
|
tabs: ResolvedWorkspaceDesktopTabRowItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceDesktopTabRowsResolverProps {
|
interface WorkspaceDesktopTabPresentationSlotProps {
|
||||||
tabs: WorkspaceDesktopTabRowItem[];
|
tab: WorkspaceTabDescriptor;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
index: number;
|
onResolve: (tabKey: string, presentation: WorkspaceTabPresentation) => void;
|
||||||
resolvedTabs: ResolvedWorkspaceDesktopTabRowItem[];
|
|
||||||
children: (tabs: ResolvedWorkspaceDesktopTabRowItem[]) => React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_RESOLVED_TAB_ROWS: ResolvedWorkspaceDesktopTabRowItem[] = [];
|
const EMPTY_RESOLVED_TAB_ROWS: ResolvedWorkspaceDesktopTabRowItem[] = [];
|
||||||
|
|
||||||
function WorkspaceDesktopTabRowsResolver({
|
function WorkspaceDesktopTabPresentationSlot({
|
||||||
tabs,
|
tab,
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
index,
|
onResolve,
|
||||||
resolvedTabs,
|
}: WorkspaceDesktopTabPresentationSlotProps) {
|
||||||
children,
|
|
||||||
}: WorkspaceDesktopTabRowsResolverProps): React.ReactElement {
|
|
||||||
const item = tabs[index];
|
|
||||||
if (!item) {
|
|
||||||
return <>{children(resolvedTabs)}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceTabPresentationResolver tab={item.tab} serverId={serverId} workspaceId={workspaceId}>
|
<WorkspaceTabPresentationResolver tab={tab} serverId={serverId} workspaceId={workspaceId}>
|
||||||
{(presentation) => {
|
{(presentation) => (
|
||||||
const nextResolvedTabs = [...resolvedTabs, { ...item, presentation }];
|
<WorkspaceDesktopTabPresentationCommit
|
||||||
return (
|
tabKey={tab.key}
|
||||||
<WorkspaceDesktopTabRowsResolver
|
presentation={presentation}
|
||||||
tabs={tabs}
|
onResolve={onResolve}
|
||||||
serverId={serverId}
|
/>
|
||||||
workspaceId={workspaceId}
|
)}
|
||||||
index={index + 1}
|
|
||||||
resolvedTabs={nextResolvedTabs}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</WorkspaceDesktopTabRowsResolver>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</WorkspaceTabPresentationResolver>
|
</WorkspaceTabPresentationResolver>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspaceDesktopTabPresentationCommit({
|
||||||
|
tabKey,
|
||||||
|
presentation,
|
||||||
|
onResolve,
|
||||||
|
}: {
|
||||||
|
tabKey: string;
|
||||||
|
presentation: WorkspaceTabPresentation;
|
||||||
|
onResolve: (tabKey: string, presentation: WorkspaceTabPresentation) => void;
|
||||||
|
}) {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
onResolve(tabKey, presentation);
|
||||||
|
}, [onResolve, presentation, tabKey]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getFallbackTabLabel(
|
function getFallbackTabLabel(
|
||||||
tab: WorkspaceTabDescriptor,
|
tab: WorkspaceTabDescriptor,
|
||||||
labels: {
|
labels: {
|
||||||
@@ -813,16 +856,61 @@ function TabChip({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) {
|
export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) {
|
||||||
|
const [presentations, setPresentations] = useState(
|
||||||
|
() => new Map<string, WorkspaceTabPresentation>(),
|
||||||
|
);
|
||||||
|
const handlePresentation = useCallback(
|
||||||
|
(tabKey: string, presentation: WorkspaceTabPresentation) => {
|
||||||
|
setPresentations((current) => {
|
||||||
|
if (current.get(tabKey) === presentation) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const next = new Map(current);
|
||||||
|
next.set(tabKey, presentation);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const currentTabKeys = useMemo(
|
||||||
|
() => new Set(props.tabs.map((item) => item.tab.key)),
|
||||||
|
[props.tabs],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
setPresentations((current) => {
|
||||||
|
const removedKeys = [...current.keys()].filter((key) => !currentTabKeys.has(key));
|
||||||
|
if (removedKeys.length === 0) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const next = new Map(current);
|
||||||
|
for (const key of removedKeys) {
|
||||||
|
next.delete(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [currentTabKeys]);
|
||||||
|
const resolvedTabs = useMemo(
|
||||||
|
() =>
|
||||||
|
props.tabs.flatMap((item) => {
|
||||||
|
const presentation = presentations.get(item.tab.key);
|
||||||
|
return presentation ? [{ ...item, presentation }] : [];
|
||||||
|
}),
|
||||||
|
[presentations, props.tabs],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceDesktopTabRowsResolver
|
<>
|
||||||
tabs={props.tabs}
|
<ResolvedWorkspaceDesktopTabsRow {...props} tabs={resolvedTabs} />
|
||||||
serverId={props.normalizedServerId}
|
{props.tabs.map(({ tab }) => (
|
||||||
workspaceId={props.normalizedWorkspaceId}
|
<WorkspaceDesktopTabPresentationSlot
|
||||||
index={0}
|
key={`${tab.key}:${tab.kind}`}
|
||||||
resolvedTabs={EMPTY_RESOLVED_TAB_ROWS}
|
tab={tab}
|
||||||
>
|
serverId={props.normalizedServerId}
|
||||||
{(tabs) => <ResolvedWorkspaceDesktopTabsRow {...props} tabs={tabs} />}
|
workspaceId={props.normalizedWorkspaceId}
|
||||||
</WorkspaceDesktopTabRowsResolver>
|
onResolve={handlePresentation}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -864,9 +952,8 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||||
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
|
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
|
||||||
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
|
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
|
||||||
const [measuredTabLabels, setMeasuredTabLabels] = useState<
|
const labelMeasurementsRef = useRef(new Map<string, WorkspaceTabLabelMeasurement>());
|
||||||
Record<string, { label: string; width: number }>
|
const [trackSnapshot, setTrackSnapshot] = useState<WorkspaceTabTrackSnapshot | null>(null);
|
||||||
>({});
|
|
||||||
const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? "";
|
const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? "";
|
||||||
const checkoutStatus = useCheckoutStatusQuery({
|
const checkoutStatus = useCheckoutStatusQuery({
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
@@ -954,32 +1041,76 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
}),
|
}),
|
||||||
[fallbackTabLabels, tabs],
|
[fallbackTabLabels, tabs],
|
||||||
);
|
);
|
||||||
const tabLabelWidths = useMemo(
|
const tabLabelSignature = useMemo(() => workspaceTabLabelSignature(tabLabels), [tabLabels]);
|
||||||
() =>
|
const publishMeasuredTrack = useCallback(() => {
|
||||||
tabLabels.map(({ key, label }) => {
|
if (tabsContainerWidth <= 0) {
|
||||||
const measurement = measuredTabLabels[key];
|
return;
|
||||||
return measurement?.label === label
|
}
|
||||||
? measurement.width + TAB_LABEL_LAYOUT_ALLOWANCE
|
const labelWidths = completeWorkspaceTabLabelWidths(tabLabels, labelMeasurementsRef.current);
|
||||||
: label.length * TAB_LABEL_FALLBACK_CHARACTER_WIDTH + TAB_LABEL_LAYOUT_ALLOWANCE;
|
if (!labelWidths) {
|
||||||
}),
|
return;
|
||||||
[measuredTabLabels, tabLabels],
|
}
|
||||||
);
|
|
||||||
|
const retainedMeasurements = new Map<string, WorkspaceTabLabelMeasurement>();
|
||||||
|
for (const { key } of tabLabels) {
|
||||||
|
const measurement = labelMeasurementsRef.current.get(key);
|
||||||
|
if (measurement) {
|
||||||
|
retainedMeasurements.set(key, measurement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labelMeasurementsRef.current = retainedMeasurements;
|
||||||
|
|
||||||
|
setTrackSnapshot((current) => {
|
||||||
|
if (
|
||||||
|
current?.signature === tabLabelSignature &&
|
||||||
|
sameWidths(current.labelWidths, labelWidths)
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
signature: tabLabelSignature,
|
||||||
|
tabs,
|
||||||
|
labels: tabLabels,
|
||||||
|
labelWidths,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [tabLabelSignature, tabLabels, tabs, tabsContainerWidth]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
publishMeasuredTrack();
|
||||||
|
}, [publishMeasuredTrack]);
|
||||||
|
|
||||||
const handleTabLabelLayout = useCallback(
|
const handleTabLabelLayout = useCallback(
|
||||||
(key: string, label: string, event: LayoutChangeEvent) => {
|
(key: string, label: string, event: LayoutChangeEvent) => {
|
||||||
const width = Math.ceil(event.nativeEvent.layout.width);
|
const width = Math.ceil(event.nativeEvent.layout.width);
|
||||||
setMeasuredTabLabels((current) => {
|
if (width <= 0) {
|
||||||
const existing = current[key];
|
return;
|
||||||
if (existing?.label === label && existing.width === width) {
|
}
|
||||||
return current;
|
const current = labelMeasurementsRef.current.get(key);
|
||||||
}
|
if (current?.label === label && current.width === width) {
|
||||||
return { ...current, [key]: { label, width } };
|
return;
|
||||||
});
|
}
|
||||||
|
labelMeasurementsRef.current.set(key, { label, width });
|
||||||
|
publishMeasuredTrack();
|
||||||
},
|
},
|
||||||
[],
|
[publishMeasuredTrack],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const displayedTabs = useMemo(() => {
|
||||||
|
if (!trackSnapshot) {
|
||||||
|
return EMPTY_RESOLVED_TAB_ROWS;
|
||||||
|
}
|
||||||
|
const currentTabs = new Map(
|
||||||
|
tabs.map((tab, index) => [tab.tab.key, { tab, label: tabLabels[index]?.label }]),
|
||||||
|
);
|
||||||
|
return trackSnapshot.tabs.map((snapshotTab, index) => {
|
||||||
|
const current = currentTabs.get(snapshotTab.tab.key);
|
||||||
|
return current?.label === trackSnapshot.labels[index]?.label ? current.tab : snapshotTab;
|
||||||
|
});
|
||||||
|
}, [tabLabels, tabs, trackSnapshot]);
|
||||||
|
|
||||||
const { layout } = useWorkspaceTabLayout({
|
const { layout } = useWorkspaceTabLayout({
|
||||||
tabLabelWidths,
|
tabLabelWidths: trackSnapshot?.labelWidths ?? [],
|
||||||
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
|
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
|
||||||
metrics: layoutMetrics,
|
metrics: layoutMetrics,
|
||||||
});
|
});
|
||||||
@@ -1058,8 +1189,8 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
const showDropIndicatorBefore = activeDragTabId !== null && tabDropPreviewIndex === index;
|
const showDropIndicatorBefore = activeDragTabId !== null && tabDropPreviewIndex === index;
|
||||||
const showDropIndicatorAfter =
|
const showDropIndicatorAfter =
|
||||||
activeDragTabId !== null &&
|
activeDragTabId !== null &&
|
||||||
tabDropPreviewIndex === tabs.length &&
|
tabDropPreviewIndex === displayedTabs.length &&
|
||||||
index === tabs.length - 1;
|
index === displayedTabs.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResolvedDesktopTabChip
|
<ResolvedDesktopTabChip
|
||||||
@@ -1068,7 +1199,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
isFocused={isFocused}
|
isFocused={isFocused}
|
||||||
isDragging={isActive}
|
isDragging={isActive}
|
||||||
index={index}
|
index={index}
|
||||||
tabCount={tabs.length}
|
tabCount={displayedTabs.length}
|
||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
onCopyTerminalId={onCopyTerminalId}
|
onCopyTerminalId={onCopyTerminalId}
|
||||||
@@ -1110,7 +1241,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
setHoveredCloseTabKey,
|
setHoveredCloseTabKey,
|
||||||
tabMenuLabels,
|
tabMenuLabels,
|
||||||
tabDropPreviewIndex,
|
tabDropPreviewIndex,
|
||||||
tabs.length,
|
displayedTabs.length,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1179,10 +1310,10 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<SortableInlineList
|
<SortableInlineList
|
||||||
data={tabs}
|
data={displayedTabs}
|
||||||
keyExtractor={tabKeyExtractor}
|
keyExtractor={tabKeyExtractor}
|
||||||
useDragHandle
|
useDragHandle
|
||||||
disabled={!externalDndContext && tabs.length < 2}
|
disabled={!externalDndContext && displayedTabs.length < 2}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
externalDndContext={externalDndContext}
|
externalDndContext={externalDndContext}
|
||||||
activeId={activeDragTabId}
|
activeId={activeDragTabId}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { computeWorkspaceTabLayout } from "@/screens/workspace/workspace-tab-layout";
|
import {
|
||||||
|
computeWorkspaceTabLayout,
|
||||||
|
retainWorkspaceTabMeasuredWidth,
|
||||||
|
} from "@/screens/workspace/workspace-tab-layout";
|
||||||
|
|
||||||
const metrics = {
|
const metrics = {
|
||||||
rowHorizontalInset: 0,
|
rowHorizontalInset: 0,
|
||||||
@@ -7,7 +10,7 @@ const metrics = {
|
|||||||
rowPaddingHorizontal: 8,
|
rowPaddingHorizontal: 8,
|
||||||
tabGap: 4,
|
tabGap: 4,
|
||||||
minTabWidth: 96,
|
minTabWidth: 96,
|
||||||
maxTabWidth: 240,
|
maxTabWidth: 160,
|
||||||
tabIconWidth: 14,
|
tabIconWidth: 14,
|
||||||
tabContentGap: 4,
|
tabContentGap: 4,
|
||||||
tabHorizontalPadding: 8,
|
tabHorizontalPadding: 8,
|
||||||
@@ -49,7 +52,7 @@ describe("computeWorkspaceTabLayout", () => {
|
|||||||
|
|
||||||
expect(result.closeButtonPolicy).toBe("all");
|
expect(result.closeButtonPolicy).toBe("all");
|
||||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||||
expect(result.items.map((item) => item.width)).toEqual([119, 101, 96]);
|
expect(result.items.map((item) => item.width)).toEqual([117, 103, 96]);
|
||||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,7 +70,7 @@ describe("computeWorkspaceTabLayout", () => {
|
|||||||
|
|
||||||
expect(result.closeButtonPolicy).toBe("all");
|
expect(result.closeButtonPolicy).toBe("all");
|
||||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||||
expect(result.items.map((item) => item.width)).toEqual([240, 240, 240, 240]);
|
expect(result.items.map((item) => item.width)).toEqual([160, 160, 160, 160]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps every tab at the clickable minimum at the exact fit boundary", () => {
|
it("keeps every tab at the clickable minimum at the exact fit boundary", () => {
|
||||||
@@ -118,3 +121,13 @@ describe("computeWorkspaceTabLayout", () => {
|
|||||||
expect(result.items.map((item) => item.width)).toEqual([102, 138]);
|
expect(result.items.map((item) => item.width)).toEqual([102, 138]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("retainWorkspaceTabMeasuredWidth", () => {
|
||||||
|
it("retains the last usable width while a retained panel is hidden", () => {
|
||||||
|
expect(retainWorkspaceTabMeasuredWidth(720, 0)).toBe(720);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the next usable layout width", () => {
|
||||||
|
expect(retainWorkspaceTabMeasuredWidth(720, 640)).toBe(640);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ export interface WorkspaceTabLayoutResult {
|
|||||||
requiresHorizontalScrollFallback: boolean;
|
requiresHorizontalScrollFallback: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function retainWorkspaceTabMeasuredWidth(
|
||||||
|
currentWidth: number,
|
||||||
|
measuredWidth: number,
|
||||||
|
): number {
|
||||||
|
if (measuredWidth <= 0 || Math.abs(currentWidth - measuredWidth) <= 1) {
|
||||||
|
return currentWidth;
|
||||||
|
}
|
||||||
|
return measuredWidth;
|
||||||
|
}
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
if (value < min) {
|
if (value < min) {
|
||||||
return min;
|
return min;
|
||||||
|
|||||||
Reference in New Issue
Block a user