mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
feat(app): make Explorer a first-class pane host (#3826)
This commit is contained in:
@@ -37,6 +37,7 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
|
||||
onActivate={activate}
|
||||
onSelect={onSelectPath}
|
||||
onOpenFile={working?.onOpenFile}
|
||||
onOpenToSide={working?.onOpenToSide}
|
||||
onAddToChat={working?.onAddToChat}
|
||||
onCopyPath={working?.onCopyPath}
|
||||
onCopyRelativePath={working?.onCopyRelativePath}
|
||||
@@ -60,6 +61,7 @@ function documentFileHeaderPropsEqual(
|
||||
previous.mode.onFilePress === next.mode.onFilePress &&
|
||||
previous.mode.workspaceFileDragScope === next.mode.workspaceFileDragScope &&
|
||||
previous.mode.onOpenFile === next.mode.onOpenFile &&
|
||||
previous.mode.onOpenToSide === next.mode.onOpenToSide &&
|
||||
previous.mode.onAddToChat === next.mode.onAddToChat &&
|
||||
previous.mode.onCopyPath === next.mode.onCopyPath &&
|
||||
previous.mode.onCopyRelativePath === next.mode.onCopyRelativePath &&
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { createDiffPalette, retainDiffPalette } from "./palette";
|
||||
import { DiffSurface } from "./surface";
|
||||
import type { DiffDocumentProps, DiffPalette } from "./types";
|
||||
@@ -47,5 +48,9 @@ const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({
|
||||
}));
|
||||
|
||||
export function DiffDocument(props: DiffDocumentProps) {
|
||||
return <StyledDiffDocument {...props} />;
|
||||
return (
|
||||
<RenderProfile id="DiffDocument">
|
||||
<StyledDiffDocument {...props} />
|
||||
</RenderProfile>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,7 @@ import { DocumentFileHeader } from "./document-file-header";
|
||||
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
|
||||
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
||||
import { HorizontalScroll } from "./horizontal-scroll.web";
|
||||
import {
|
||||
buildDiffDocumentModel,
|
||||
FILE_HEADER_HEIGHT,
|
||||
resolveRelayoutScrollTop,
|
||||
retainReusableModels,
|
||||
} from "./model";
|
||||
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
|
||||
import { paintWebViewport } from "./paint.web";
|
||||
import { hasPointerDragStarted } from "./pointer-gesture";
|
||||
import { createMeasuredAdvances } from "./text-measurement";
|
||||
@@ -25,22 +20,20 @@ import type {
|
||||
DiffTypography,
|
||||
TextMeasurer,
|
||||
} from "./types";
|
||||
import { useDiffDocumentWorkspaceCache } from "./workspace-cache";
|
||||
|
||||
const DEFAULT_MONO_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
|
||||
const RESIZE_SETTLE_DELAY_MS = 120;
|
||||
|
||||
export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const { t } = useTranslation();
|
||||
const workspaceCache = useDiffDocumentWorkspaceCache();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
||||
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
||||
const reusableModelRef = useRef<{
|
||||
dependencies: readonly unknown[];
|
||||
models: ReturnType<typeof buildDiffDocumentModel>[];
|
||||
} | null>(null);
|
||||
const consumedFocusRef = useRef<string | null>(null);
|
||||
const scrollTopRef = useRef(0);
|
||||
const horizontalOffsetsRef = useRef(new Map<string, number>());
|
||||
@@ -60,7 +53,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const forcePaintRef = useRef(true);
|
||||
const canvasWindowRef = useRef({ top: 0, height: 0 });
|
||||
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
||||
const [loadedTypography, setLoadedTypography] = useState<DiffTypography | null>(null);
|
||||
const [hoveredAffordance, setHoveredAffordance] = useState<{
|
||||
hit: Extract<DiffHit, { kind: "cell" }>;
|
||||
left: number;
|
||||
@@ -81,10 +73,28 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
}),
|
||||
[family, props.displayPreferences.codeFontSize],
|
||||
);
|
||||
const measurement = useMemo(
|
||||
() => (loadedTypography ? createWebTextMeasurer(loadedTypography) : null),
|
||||
[loadedTypography],
|
||||
const typographyResource = useMemo(
|
||||
() =>
|
||||
workspaceCache.typography({
|
||||
typography: desiredTypography,
|
||||
load: async () => {
|
||||
await Promise.all([
|
||||
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
|
||||
document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`),
|
||||
]);
|
||||
await document.fonts.ready;
|
||||
},
|
||||
createMeasurer: () => createWebTextMeasurer(desiredTypography),
|
||||
}),
|
||||
[desiredTypography, workspaceCache],
|
||||
);
|
||||
const [readyTypographyResource, setReadyTypographyResource] = useState<ReturnType<
|
||||
typeof workspaceCache.typography
|
||||
> | null>(() => (typographyResource.isReady() ? typographyResource : null));
|
||||
const loadedTypography =
|
||||
readyTypographyResource === typographyResource ? typographyResource.typography : null;
|
||||
const measurement =
|
||||
readyTypographyResource === typographyResource ? typographyResource.measureText : null;
|
||||
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
|
||||
const model = useMemo(() => {
|
||||
if (!loadedTypography || !measurement) {
|
||||
@@ -95,22 +105,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
lineHeight: desiredTypography.lineHeight,
|
||||
});
|
||||
}
|
||||
const dependencies = [
|
||||
props.files,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
viewport.width,
|
||||
loadedTypography,
|
||||
measurement,
|
||||
props.palette,
|
||||
t,
|
||||
] as const;
|
||||
const previous = reusableModelRef.current;
|
||||
const canReuse = previous?.dependencies.every(
|
||||
(dependency, index) => dependency === dependencies[index],
|
||||
);
|
||||
const reuseFrom = canReuse ? previous?.models : undefined;
|
||||
const next = buildDiffDocumentModel({
|
||||
return workspaceCache.buildModel({
|
||||
files: props.files,
|
||||
collapsedFilePaths: props.collapsedFilePaths,
|
||||
layout: props.displayPreferences.layout,
|
||||
@@ -124,13 +119,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
binary: t("workspace.git.diff.binaryFile"),
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
reuseFrom,
|
||||
});
|
||||
reusableModelRef.current = {
|
||||
dependencies,
|
||||
models: retainReusableModels(reuseFrom, next),
|
||||
};
|
||||
return next;
|
||||
}, [
|
||||
measurement,
|
||||
props.collapsedFilePaths,
|
||||
@@ -143,6 +132,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
desiredTypography.lineHeight,
|
||||
loadedTypography,
|
||||
viewport.width,
|
||||
workspaceCache,
|
||||
]);
|
||||
modelRef.current = model;
|
||||
|
||||
@@ -280,18 +270,19 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await Promise.all([
|
||||
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
|
||||
document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`),
|
||||
]);
|
||||
await document.fonts.ready;
|
||||
if (!cancelled) setLoadedTypography(desiredTypography);
|
||||
})();
|
||||
if (typographyResource.isReady()) {
|
||||
setReadyTypographyResource(typographyResource);
|
||||
return;
|
||||
}
|
||||
setReadyTypographyResource(null);
|
||||
void typographyResource.load().then(() => {
|
||||
if (!cancelled) setReadyTypographyResource(typographyResource);
|
||||
return undefined;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [desiredTypography]);
|
||||
}, [typographyResource]);
|
||||
useLayoutEffect(() => {
|
||||
const previous = previousModelRef.current;
|
||||
const scroll = scrollRef.current;
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface WorkingDiffMode {
|
||||
focusRequestId?: number;
|
||||
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenToSide?: (path: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
onCopyPath?: (path: string) => void;
|
||||
onCopyRelativePath?: (path: string) => void;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDiffDocumentWorkspaceCache } from "./workspace-cache";
|
||||
import type { BuildDiffDocumentModelInput, DiffPalette, TextMeasurer } from "./types";
|
||||
|
||||
const palette: DiffPalette = {
|
||||
surface: "#000",
|
||||
headerSurface: "#111",
|
||||
border: "#222",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
addition: "green",
|
||||
deletion: "red",
|
||||
additionBackground: "#010",
|
||||
deletionBackground: "#100",
|
||||
emptyBackground: "#111",
|
||||
selection: "blue",
|
||||
syntax: { keyword: "purple" },
|
||||
};
|
||||
|
||||
function diffFile(): ParsedDiffFile {
|
||||
return {
|
||||
path: "src/a.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 0,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [{ type: "add", content: "const answer = 42;" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function countingMeasurer() {
|
||||
const stats = { calls: 0 };
|
||||
const measureText: TextMeasurer = {
|
||||
measure(text) {
|
||||
stats.calls += 1;
|
||||
return text.length * 8;
|
||||
},
|
||||
};
|
||||
return { measureText, stats };
|
||||
}
|
||||
|
||||
function modelInput(
|
||||
files: readonly ParsedDiffFile[],
|
||||
measureText: TextMeasurer,
|
||||
overrides: Partial<BuildDiffDocumentModelInput> = {},
|
||||
): BuildDiffDocumentModelInput {
|
||||
return {
|
||||
files,
|
||||
collapsedFilePaths: new Set(),
|
||||
layout: "unified",
|
||||
wrapLines: false,
|
||||
viewportWidth: 800,
|
||||
typography: { family: "monospace", size: 12, lineHeight: 18 },
|
||||
measureText,
|
||||
palette,
|
||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("diff document workspace cache", () => {
|
||||
it("reuses measured rows until a model-building input changes", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const files = [diffFile()];
|
||||
const { measureText, stats } = countingMeasurer();
|
||||
const input = modelInput(files, measureText);
|
||||
|
||||
const first = cache.buildModel(input);
|
||||
expect(stats.calls).toBeGreaterThan(0);
|
||||
|
||||
stats.calls = 0;
|
||||
const second = cache.buildModel(input);
|
||||
expect(stats.calls).toBe(0);
|
||||
expect(second).toBe(first);
|
||||
|
||||
const collapsed = cache.buildModel({
|
||||
...input,
|
||||
collapsedFilePaths: new Set(["src/a.ts"]),
|
||||
});
|
||||
expect(collapsed).not.toBe(first);
|
||||
expect(collapsed.files[0]?.isCollapsed).toBe(true);
|
||||
|
||||
cache.buildModel({ ...input, viewportWidth: 640 });
|
||||
expect(stats.calls).toBeGreaterThan(0);
|
||||
|
||||
stats.calls = 0;
|
||||
cache.buildModel({
|
||||
...input,
|
||||
typography: { family: "monospace", size: 13, lineHeight: 20 },
|
||||
});
|
||||
expect(stats.calls).toBeGreaterThan(0);
|
||||
|
||||
stats.calls = 0;
|
||||
cache.buildModel({ ...input, palette: { ...palette, foreground: "#eee" } });
|
||||
expect(stats.calls).toBeGreaterThan(0);
|
||||
|
||||
stats.calls = 0;
|
||||
cache.buildModel({ ...input, files: [...files] });
|
||||
expect(stats.calls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("bounds retained geometry variants for one diff payload", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const files = [diffFile()];
|
||||
const { measureText, stats } = countingMeasurer();
|
||||
|
||||
for (const viewportWidth of [600, 700, 800, 900, 1000]) {
|
||||
cache.buildModel(modelInput(files, measureText, { viewportWidth }));
|
||||
}
|
||||
|
||||
stats.calls = 0;
|
||||
cache.buildModel(modelInput(files, measureText, { viewportWidth: 600 }));
|
||||
expect(stats.calls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("invalidates status rows when their translated labels change", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const { measureText } = countingMeasurer();
|
||||
const files: ParsedDiffFile[] = [{ ...diffFile(), status: "binary", hunks: [] }];
|
||||
const first = cache.buildModel(modelInput(files, measureText));
|
||||
const second = cache.buildModel(
|
||||
modelInput(files, measureText, {
|
||||
labels: { binary: "Binary blob", tooLarge: "Too large" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(first.rows[0]).toMatchObject({ kind: "status", label: "Binary" });
|
||||
expect(second.rows[0]).toMatchObject({ kind: "status", label: "Binary blob" });
|
||||
});
|
||||
|
||||
it("shares loaded typography and its text measurer across mounts", async () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const { measureText } = countingMeasurer();
|
||||
let loadCount = 0;
|
||||
let measurerCount = 0;
|
||||
const typography = { family: "monospace", size: 12, lineHeight: 18 };
|
||||
const resource = cache.typography({
|
||||
typography,
|
||||
load: async () => {
|
||||
loadCount += 1;
|
||||
},
|
||||
createMeasurer: () => {
|
||||
measurerCount += 1;
|
||||
return measureText;
|
||||
},
|
||||
});
|
||||
const reused = cache.typography({
|
||||
typography: { ...typography },
|
||||
load: async () => {
|
||||
loadCount += 1;
|
||||
},
|
||||
createMeasurer: () => {
|
||||
measurerCount += 1;
|
||||
return measureText;
|
||||
},
|
||||
});
|
||||
|
||||
expect(reused).toBe(resource);
|
||||
expect(measurerCount).toBe(1);
|
||||
await Promise.all([resource.load(), reused.load()]);
|
||||
expect(loadCount).toBe(1);
|
||||
expect(resource.isReady()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { buildDiffDocumentModel, retainReusableModels } from "./model";
|
||||
import type {
|
||||
BuildDiffDocumentModelInput,
|
||||
DiffDocumentModel,
|
||||
DiffPalette,
|
||||
DiffTypography,
|
||||
TextMeasurer,
|
||||
} from "./types";
|
||||
|
||||
const MAX_MODEL_VARIANTS = 4;
|
||||
const MAX_TYPOGRAPHY_RESOURCES = 4;
|
||||
|
||||
interface ModelVariant {
|
||||
key: string;
|
||||
exactKey: string;
|
||||
model: DiffDocumentModel;
|
||||
models: DiffDocumentModel[];
|
||||
}
|
||||
|
||||
interface TypographyResourceInput {
|
||||
typography: DiffTypography;
|
||||
load: () => Promise<void>;
|
||||
createMeasurer: () => TextMeasurer;
|
||||
}
|
||||
|
||||
export interface DiffTypographyResource {
|
||||
typography: DiffTypography;
|
||||
measureText: TextMeasurer;
|
||||
isReady(): boolean;
|
||||
load(): Promise<DiffTypography>;
|
||||
}
|
||||
|
||||
export interface DiffDocumentWorkspaceCache {
|
||||
buildModel(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): DiffDocumentModel;
|
||||
typography(input: TypographyResourceInput): DiffTypographyResource;
|
||||
}
|
||||
|
||||
export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
|
||||
const variantsByFiles = new WeakMap<BuildDiffDocumentModelInput["files"], ModelVariant[]>();
|
||||
const typographyResources = new Map<string, DiffTypographyResource>();
|
||||
|
||||
return {
|
||||
buildModel(input) {
|
||||
const key = modelVariantKey(input);
|
||||
const exactKey = exactModelKey(input);
|
||||
const variants = variantsByFiles.get(input.files) ?? [];
|
||||
const variantIndex = variants.findIndex((candidate) => candidate.key === key);
|
||||
const variant = variantIndex === -1 ? undefined : variants[variantIndex];
|
||||
if (variant?.exactKey === exactKey) {
|
||||
variants.splice(variantIndex, 1);
|
||||
variants.unshift(variant);
|
||||
return variant.model;
|
||||
}
|
||||
const model = buildDiffDocumentModel({ ...input, reuseFrom: variant?.models });
|
||||
const nextVariant = {
|
||||
key,
|
||||
exactKey,
|
||||
model,
|
||||
models: retainReusableModels(variant?.models, model),
|
||||
};
|
||||
if (variantIndex !== -1) variants.splice(variantIndex, 1);
|
||||
variants.unshift(nextVariant);
|
||||
variantsByFiles.set(input.files, variants.slice(0, MAX_MODEL_VARIANTS));
|
||||
return model;
|
||||
},
|
||||
typography(input) {
|
||||
const key = typographyKey(input.typography);
|
||||
const existing = typographyResources.get(key);
|
||||
if (existing) {
|
||||
typographyResources.delete(key);
|
||||
typographyResources.set(key, existing);
|
||||
return existing;
|
||||
}
|
||||
const resource = createTypographyResource(input);
|
||||
typographyResources.set(key, resource);
|
||||
while (typographyResources.size > MAX_TYPOGRAPHY_RESOURCES) {
|
||||
const oldestKey = typographyResources.keys().next().value;
|
||||
if (oldestKey === undefined) break;
|
||||
typographyResources.delete(oldestKey);
|
||||
}
|
||||
return resource;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTypographyResource(input: TypographyResourceInput): DiffTypographyResource {
|
||||
let ready = false;
|
||||
let pending: Promise<DiffTypography> | null = null;
|
||||
return {
|
||||
typography: input.typography,
|
||||
measureText: input.createMeasurer(),
|
||||
isReady: () => ready,
|
||||
load() {
|
||||
if (ready) return Promise.resolve(input.typography);
|
||||
pending ??= input.load().then(() => {
|
||||
ready = true;
|
||||
return input.typography;
|
||||
});
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function modelVariantKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string {
|
||||
return JSON.stringify([
|
||||
input.layout,
|
||||
input.wrapLines,
|
||||
input.viewportWidth,
|
||||
typographyKey(input.typography),
|
||||
paletteKey(input.palette),
|
||||
input.labels.binary,
|
||||
input.labels.tooLarge,
|
||||
]);
|
||||
}
|
||||
|
||||
function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string {
|
||||
const collapsedFilePaths = [...input.collapsedFilePaths].sort();
|
||||
const reviewGeometry = input.reviewActions
|
||||
? {
|
||||
comments: [...input.reviewActions.commentsByTarget.entries()]
|
||||
.map(([target, comments]) => [target, comments.map((comment) => comment.id).sort()])
|
||||
.sort(([left], [right]) => String(left).localeCompare(String(right))),
|
||||
editor: input.reviewActions.editor
|
||||
? [input.reviewActions.editor.target.key, input.reviewActions.editor.commentId]
|
||||
: null,
|
||||
}
|
||||
: null;
|
||||
return JSON.stringify([collapsedFilePaths, reviewGeometry]);
|
||||
}
|
||||
|
||||
function typographyKey(typography: DiffTypography): string {
|
||||
return JSON.stringify([typography.family, typography.size, typography.lineHeight]);
|
||||
}
|
||||
|
||||
function paletteKey(palette: DiffPalette): string {
|
||||
const syntax = Object.keys(palette.syntax)
|
||||
.sort()
|
||||
.map((name) => [name, palette.syntax[name]]);
|
||||
return JSON.stringify([
|
||||
palette.surface,
|
||||
palette.headerSurface,
|
||||
palette.border,
|
||||
palette.foreground,
|
||||
palette.foregroundMuted,
|
||||
palette.addition,
|
||||
palette.deletion,
|
||||
palette.additionBackground,
|
||||
palette.deletionBackground,
|
||||
palette.emptyBackground,
|
||||
palette.selection,
|
||||
syntax,
|
||||
]);
|
||||
}
|
||||
|
||||
const WorkspaceCacheContext = createContext<DiffDocumentWorkspaceCache | null>(null);
|
||||
|
||||
export function DiffDocumentWorkspaceCacheProvider({ children }: { children: ReactNode }) {
|
||||
const [cache] = useState(createDiffDocumentWorkspaceCache);
|
||||
return <WorkspaceCacheContext.Provider value={cache}>{children}</WorkspaceCacheContext.Provider>;
|
||||
}
|
||||
|
||||
export function useDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
|
||||
const cache = useContext(WorkspaceCacheContext);
|
||||
invariant(cache, "DiffDocumentWorkspaceCacheProvider is required");
|
||||
return cache;
|
||||
}
|
||||
Reference in New Issue
Block a user