дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-12 12:45:39 +02:00
Порівняти коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
1e0ce64b0c | ||
|
|
6e7b39023c | ||
|
|
c5d319c298 | ||
|
|
0780171704 | ||
|
|
0324a81ce4 |
No files matched your search
Створено
+2
-3
@@ -2637,7 +2637,6 @@ version = "1.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"rustversion",
|
||||
"typenum",
|
||||
]
|
||||
@@ -7712,9 +7711,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "russh"
|
||||
version = "0.64.1"
|
||||
version = "0.65.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58"
|
||||
checksum = "94be3d86c71b9ed220a675c405e35c7a02565a009bf8da2e9cc13a700320a5c5"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"bitflags 2.13.2",
|
||||
|
||||
@@ -18,7 +18,7 @@ dirs.workspace = true
|
||||
uuid.workspace = true
|
||||
keyring.workspace = true
|
||||
keyring-core.workspace = true
|
||||
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
|
||||
russh = { version = "0.65", default-features = false, features = ["ring", "rsa", "flate2"] }
|
||||
russh-sftp = "2.4"
|
||||
flate2 = "1"
|
||||
suppaftp = { version = "12.2.1", default-features = false, features = ["tokio-rustls-ring"] }
|
||||
|
||||
@@ -44,8 +44,9 @@ impl server::Handler for Sshd {
|
||||
Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
|
||||
async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
|
||||
Ok(if user == "dev" && key.key_data() == self.client_key.key_data() { Auth::Accept } else { Auth::reject() })
|
||||
async fn auth_publickey(&mut self, user: &str, key: server::VerifiedPublicKey<'_>) -> Result<Auth, Self::Error> {
|
||||
let accept = user == "dev" && key.public_key.key_data() == self.client_key.key_data();
|
||||
Ok(if accept { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
|
||||
async fn channel_open_session(
|
||||
|
||||
@@ -103,3 +103,70 @@ test("Select All takes the whole buffer, not just the mounted rows", async ({ pa
|
||||
expect(copied).toContain("output 0\n");
|
||||
expect(copied).toMatch(/output 499$/);
|
||||
});
|
||||
|
||||
/** Mount the real console over the given output, wrapped or not. */
|
||||
async function consoleOf(page: Page, text: string, softWrap: boolean) {
|
||||
await page.goto("/");
|
||||
await page.evaluate(
|
||||
async ({ text, softWrap }) => {
|
||||
const reactPath = "/node_modules/.vite/deps/react.js";
|
||||
const domPath = "/node_modules/.vite/deps/react-dom_client.js";
|
||||
const viewPath = "/src/features/run/ConsoleView.tsx";
|
||||
const modelPath = "/src/features/run/console.ts";
|
||||
const react = await import(reactPath);
|
||||
const dom = await import(domPath);
|
||||
const { createElement, useState } = react.default ?? react;
|
||||
const { createRoot } = dom.default ?? dom;
|
||||
const { ConsoleView } = await import(viewPath);
|
||||
const { ConsoleModel } = await import(modelPath);
|
||||
const model = new ConsoleModel();
|
||||
model.append({ kind: "stdout", text });
|
||||
const host = document.createElement("div");
|
||||
host.style.cssText = "position:fixed;inset:0;z-index:99999;display:flex;height:300px;background:white";
|
||||
document.body.append(host);
|
||||
function Console() {
|
||||
const [following, setFollowing] = useState(false);
|
||||
return createElement(ConsoleView, {
|
||||
model,
|
||||
version: 1,
|
||||
softWrap,
|
||||
follow: following,
|
||||
onFollowChange: setFollowing,
|
||||
cwd: "/",
|
||||
});
|
||||
}
|
||||
createRoot(host).render(createElement(Console));
|
||||
},
|
||||
{ text, softWrap },
|
||||
);
|
||||
const log = page.getByRole("log", { name: "Console", exact: true });
|
||||
await expect(log.locator("[data-index]").first()).toBeVisible();
|
||||
return log;
|
||||
}
|
||||
|
||||
for (const softWrap of [false, true]) {
|
||||
test(`takes a blank output line as a line break, not a zero-width space (soft wrap ${softWrap ? "on" : "off"})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const log = await consoleOf(page, "first\n\nsecond\n", softWrap);
|
||||
// A blank line is still a whole row: stripping the character must not shrink it.
|
||||
const heights = await log.evaluate((el) =>
|
||||
[...el.querySelectorAll("[data-index]")].map((row) => Math.round(row.getBoundingClientRect().height)),
|
||||
);
|
||||
expect(heights).toHaveLength(3);
|
||||
expect(new Set(heights).size).toBe(1);
|
||||
expect(heights[0]).toBeGreaterThan(0);
|
||||
|
||||
const selected = await log.evaluate((el) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el.firstElementChild!);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return selection.toString();
|
||||
});
|
||||
expect(selected).not.toContain("\u200b");
|
||||
// Chromium drops the trailing break of a selection; the blank line in the middle stays one.
|
||||
expect(selected.split("\n")).toEqual(["first", "", "second"]);
|
||||
});
|
||||
}
|
||||
@@ -11,4 +11,12 @@
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<!-- Win32 calls in dependencies (gitoxide's ownership check of a submodule's
|
||||
`<worktree>/../../.git/modules/…` path) accept paths past MAX_PATH when
|
||||
the system enables long paths. -->
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -56,7 +56,7 @@ async function applyEdit(view: EditorView, edit: WorkspaceEdit) {
|
||||
async function prepareIndexes(view: EditorView, path: string): Promise<boolean> {
|
||||
const before = view.state.doc;
|
||||
try {
|
||||
await synchronizeDocumentIndexes(path);
|
||||
await synchronizeDocumentIndexes(path, "refactor");
|
||||
return view.state.doc === before;
|
||||
} catch (e) {
|
||||
reportLspError(view, e, true);
|
||||
@@ -269,7 +269,7 @@ async function finishRename(view: EditorView, doc: EditorDocContext, pos: Pos, o
|
||||
let renamed;
|
||||
const before = view.state.doc;
|
||||
try {
|
||||
await synchronizeDocumentIndexes(doc.path);
|
||||
await synchronizeDocumentIndexes(doc.path, "refactor");
|
||||
if (renameRequest !== request || view.state.doc !== before) return;
|
||||
renamed = await lspRequest(() => commands.lspRename(request, doc.doc, pos, newName));
|
||||
if (renameRequest !== request) return;
|
||||
|
||||
@@ -160,7 +160,7 @@ describe("findUsages", () => {
|
||||
const pending = findUsages();
|
||||
const at = (line: number) => ({ path: "/p/b.rs", range: { start: { line, col: 4 }, end: { line, col: 8 } } });
|
||||
try {
|
||||
await vi.waitFor(() => expect(sync).toHaveBeenCalledWith("/p/a.rs"));
|
||||
await vi.waitFor(() => expect(sync).toHaveBeenCalledWith("/p/a.rs", "usages"));
|
||||
expect(lspReferences).not.toHaveBeenCalled();
|
||||
expect(channels).toHaveLength(allocated);
|
||||
release();
|
||||
@@ -181,6 +181,23 @@ describe("findUsages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findUsages with a contributed index that is not up to date", () => {
|
||||
it("still searches, and says plugin usages may be missing", async () => {
|
||||
const off = registerDocumentIndexSync(async () => false);
|
||||
const at = { path: "/p/b.rs", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } };
|
||||
referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null });
|
||||
try {
|
||||
await findUsages();
|
||||
const search = activeUsageSearch(useUsages.getState())!;
|
||||
expect(search.groups).toHaveLength(1);
|
||||
expect(search.note).toBe("May be incomplete: plugin indexes are still updating");
|
||||
} finally {
|
||||
off();
|
||||
resetUsages();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage tabs", () => {
|
||||
const search = (id: number) => ({
|
||||
id,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { clearLineTokens } from "@/editor/lineTokens";
|
||||
import { showPendingHint } from "@/editor/lsp/hint";
|
||||
import { incompleteNote } from "@/editor/lsp/lsp";
|
||||
import { commands, IpcError, isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { synchronizeDocumentIndexes, toolWindowService } from "@/workbench/contributions";
|
||||
import {
|
||||
@@ -230,7 +231,7 @@ export async function fetchUsages(
|
||||
let note: string | null = null;
|
||||
let librariesSkipped = false;
|
||||
await caretSynced(c);
|
||||
await synchronizeDocumentIndexes(c.path);
|
||||
const indexed = await synchronizeDocumentIndexes(c.path, "usages");
|
||||
if (c.view && c.view.state.doc !== before) throw new IpcError({ kind: "cancelled", message: "", data: null });
|
||||
let settled = false;
|
||||
const channel = new Channel<References>();
|
||||
@@ -268,6 +269,7 @@ export async function fetchUsages(
|
||||
locs = fb.locs;
|
||||
note = fb.note;
|
||||
}
|
||||
if (!indexed) note ??= tx("May be incomplete: plugin indexes are still updating");
|
||||
const files = await readManyFileLines(pathsToRead(locs, classified));
|
||||
return { groups: groupUsages(locs, files, classified), note, librariesSkipped };
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ it("flushes pending changes before usages or rename without waiting for the debo
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
mocks.index.mockClear();
|
||||
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
|
||||
await synchronizeDocumentIndexes("/project/A.xml");
|
||||
await synchronizeDocumentIndexes("/project/A.xml", "refactor");
|
||||
expect(mocks.index).toHaveBeenCalledExactlyOnceWith(["/project/A.xml"], expect.any(Number));
|
||||
});
|
||||
|
||||
@@ -153,7 +153,7 @@ it("waits for an active batch, then indexes changes queued while it was running"
|
||||
await vi.advanceTimersByTimeAsync(40);
|
||||
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.java"], removed: [] });
|
||||
let settled = false;
|
||||
const waiting = synchronizeDocumentIndexes("/project/A.xml").then(() => {
|
||||
const waiting = synchronizeDocumentIndexes("/project/A.xml", "refactor").then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
@@ -168,7 +168,33 @@ it("refuses refactoring if a queued index batch failed", async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
mocks.index.mockResolvedValueOnce({ status: "error", error: { kind: "unavailable", message: "indexer failed" } });
|
||||
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
|
||||
await expect(synchronizeDocumentIndexes("/project/A.xml")).rejects.toThrow("Plugin indexes are still updating");
|
||||
await expect(synchronizeDocumentIndexes("/project/A.xml", "refactor")).rejects.toThrow(
|
||||
"Plugin indexes could not be updated",
|
||||
);
|
||||
});
|
||||
|
||||
it("lets usages go ahead, marked incomplete, while a failed batch keeps failing", async () => {
|
||||
off = startPluginNavigation();
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
const failure = { status: "error", error: { kind: "unavailable", message: "indexer failed" } };
|
||||
mocks.index.mockResolvedValue(failure);
|
||||
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
|
||||
await vi.advanceTimersByTimeAsync(40);
|
||||
// Every later search used to fail with "still updating" although nothing was running.
|
||||
await expect(synchronizeDocumentIndexes("/project/A.java", "usages")).resolves.toBe(false);
|
||||
await expect(synchronizeDocumentIndexes("/project/A.java", "usages")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("retries a failed batch before the next usages or refactoring", async () => {
|
||||
off = startPluginNavigation();
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
mocks.index.mockResolvedValueOnce({ status: "error", error: { kind: "unavailable", message: "indexer failed" } });
|
||||
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
|
||||
await vi.advanceTimersByTimeAsync(40);
|
||||
mocks.index.mockClear();
|
||||
await expect(synchronizeDocumentIndexes("/project/A.java", "refactor")).resolves.toBe(true);
|
||||
expect(mocks.index).toHaveBeenCalledWith(["/project/A.xml"], expect.any(Number));
|
||||
await expect(synchronizeDocumentIndexes("/project/A.java", "usages")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it.each(["/project/notes.txt", "/external/Other.xml", "jdt://contents/Other.xml"])(
|
||||
@@ -179,13 +205,13 @@ it.each(["/project/notes.txt", "/external/Other.xml", "jdt://contents/Other.xml"
|
||||
mocks.index.mockImplementationOnce(() => new Promise(() => {}));
|
||||
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
|
||||
await vi.advanceTimersByTimeAsync(40);
|
||||
await synchronizeDocumentIndexes(path);
|
||||
await synchronizeDocumentIndexes(path, "refactor");
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps unrelated language-server queries available when plugin discovery fails", async () => {
|
||||
mocks.files.mockRejectedValue(new Error("Plugin file inventory is unavailable"));
|
||||
off = startPluginNavigation();
|
||||
await expect(synchronizeDocumentIndexes("/project/notes.txt")).resolves.toBeUndefined();
|
||||
await expect(synchronizeDocumentIndexes("/project/notes.txt", "refactor")).resolves.toBe(true);
|
||||
expect(mocks.index).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -22,9 +22,12 @@ export function startPluginNavigation(): () => void {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pending = new Set<string>();
|
||||
const dirty = new Set<string>();
|
||||
/** Paths whose last index batch failed: retried on the next usages or refactoring. */
|
||||
const failed = new Set<string>();
|
||||
const views = new Map<EditorView, string>();
|
||||
let discovered = false;
|
||||
let discovering = false;
|
||||
let discoveryFailed = false;
|
||||
const known = new Set<string>();
|
||||
let signature = "";
|
||||
let reportedError = false;
|
||||
@@ -79,13 +82,22 @@ export function startPluginNavigation(): () => void {
|
||||
await call(commands.pluginNavigationIndex(paths, id));
|
||||
if (at === generation) {
|
||||
paths.forEach((p) => {
|
||||
failed.delete(p);
|
||||
if (!pending.has(p)) dirty.delete(p);
|
||||
});
|
||||
notify();
|
||||
}
|
||||
} catch (error) {
|
||||
if (at === generation && isCancelled(error)) paths.forEach((p) => pending.add(p));
|
||||
else if (at === generation) report(error);
|
||||
else if (at === generation) {
|
||||
// Not "still updating": the batch is done, its paths just have no fresh index.
|
||||
paths.forEach((p) => {
|
||||
if (pending.has(p)) return;
|
||||
dirty.delete(p);
|
||||
failed.add(p);
|
||||
});
|
||||
report(error);
|
||||
}
|
||||
} finally {
|
||||
request = null;
|
||||
running = false;
|
||||
@@ -120,12 +132,14 @@ export function startPluginNavigation(): () => void {
|
||||
if (at !== generation) return;
|
||||
fileExtensions = new Set(inventory.extensions);
|
||||
discovered = !inventory.indexing;
|
||||
discoveryFailed = false;
|
||||
const fresh = inventory.paths.filter((p) => !known.has(p));
|
||||
inventory.paths.forEach((p) => known.add(p));
|
||||
queue(fresh);
|
||||
queue([...views.values()], true);
|
||||
} catch (error) {
|
||||
discovered = false;
|
||||
discoveryFailed = true;
|
||||
report(error);
|
||||
} finally {
|
||||
if (at === generation) discovering = false;
|
||||
@@ -139,9 +153,11 @@ export function startPluginNavigation(): () => void {
|
||||
clearTimeout(timer);
|
||||
pending.clear();
|
||||
dirty.clear();
|
||||
failed.clear();
|
||||
known.clear();
|
||||
discovered = false;
|
||||
discovering = false;
|
||||
discoveryFailed = false;
|
||||
discovery = null;
|
||||
reportedError = false;
|
||||
notify();
|
||||
@@ -151,32 +167,41 @@ export function startPluginNavigation(): () => void {
|
||||
.catch(() => {});
|
||||
}
|
||||
const offs = [
|
||||
registerDocumentIndexSync(async (path) => {
|
||||
registerDocumentIndexSync(async (path, purpose) => {
|
||||
if (!ready()) return;
|
||||
const root = useProject.getState().project!.root;
|
||||
if (!path.startsWith(root.endsWith("/") ? root : `${root}/`)) return;
|
||||
const at = generation;
|
||||
await discover();
|
||||
if (!eligible(path)) return;
|
||||
if (failed.size) queue([...failed]);
|
||||
while (ready() && at === generation && (pending.size || flushing)) {
|
||||
clearTimeout(timer);
|
||||
await flush();
|
||||
}
|
||||
if (at !== generation || !discovered || dirty.size) {
|
||||
throw new Error(tx`Plugin indexes are still updating; try again shortly`);
|
||||
const complete = at === generation && discovered && !dirty.size && !failed.size;
|
||||
if (complete) return true;
|
||||
if (purpose === "usages") return false;
|
||||
if (at === generation && (failed.size || discoveryFailed)) {
|
||||
throw new Error(tx`Plugin indexes could not be updated; see the plugin notification`);
|
||||
}
|
||||
throw new Error(tx`Plugin indexes are still updating; try again shortly`);
|
||||
}),
|
||||
pluginsBackend().onEvent((event) => {
|
||||
if (event.type === "documentIndexesChanged") reset();
|
||||
}),
|
||||
registerNavigationProvider(async (view, offset) => {
|
||||
const doc = docOf(view);
|
||||
if (!ready() || !doc || doc.virtual || !eligible(doc.path) || dirty.has(doc.path)) return null;
|
||||
if (!ready() || !doc || doc.virtual || !eligible(doc.path) || dirty.has(doc.path) || failed.has(doc.path))
|
||||
return null;
|
||||
const before = view.state.doc;
|
||||
await whenSynced(view);
|
||||
const result = await call(commands.pluginNavigationGoto(doc.doc, offsetToPos(before, offset)));
|
||||
if (!result || view.state.doc !== before) return null;
|
||||
return { ...rangeToOffsets(before, result.range), targets: result.targets.filter((t) => !dirty.has(t.path)) };
|
||||
return {
|
||||
...rangeToOffsets(before, result.range),
|
||||
targets: result.targets.filter((t) => !dirty.has(t.path) && !failed.has(t.path)),
|
||||
};
|
||||
}),
|
||||
registerEditorContribution({
|
||||
id: "plugins.navigation",
|
||||
|
||||
@@ -51,7 +51,8 @@ const Line = memo(
|
||||
if (link.kind === "url") void openUrl(link.url);
|
||||
else void openTarget(link.target, cwd);
|
||||
};
|
||||
if (segments.length === 0) return <>{""}</>;
|
||||
// A blank line is an empty row: its height comes from CSS, never from a character in the text.
|
||||
if (segments.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
@@ -188,6 +189,12 @@ export function ConsoleView({
|
||||
const editorFont = useProject((s) => s.settings?.editor);
|
||||
const consoleFont = resolveMonoFont(terminalFont, editorFont);
|
||||
const rowHeight = Math.max(16, Math.round(consoleFont.size * 1.5));
|
||||
/** A row's box: at its virtual offset, and never shorter than one line — a blank line has no text. */
|
||||
const rowStyle = (start: number) => ({
|
||||
transform: `translateY(${start}px)`,
|
||||
height: softWrap ? undefined : rowHeight,
|
||||
minHeight: rowHeight,
|
||||
});
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set());
|
||||
const [internalFindOpen, setInternalFindOpen] = useState(false);
|
||||
const findOpen = controlledFindOpen ?? internalFindOpen;
|
||||
@@ -567,7 +574,7 @@ export function ConsoleView({
|
||||
data-index={item.index}
|
||||
ref={softWrap ? virtualizer.measureElement : undefined}
|
||||
className={styles.line}
|
||||
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : rowHeight }}
|
||||
style={rowStyle(item.start)}
|
||||
>
|
||||
{pendingInput}
|
||||
</div>
|
||||
@@ -580,7 +587,7 @@ export function ConsoleView({
|
||||
data-index={item.index}
|
||||
ref={softWrap ? virtualizer.measureElement : undefined}
|
||||
className={styles.line}
|
||||
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : rowHeight }}
|
||||
style={rowStyle(item.start)}
|
||||
>
|
||||
{foldButton(row.fold, false)}
|
||||
</div>
|
||||
@@ -594,7 +601,7 @@ export function ConsoleView({
|
||||
data-index={item.index}
|
||||
ref={softWrap ? virtualizer.measureElement : undefined}
|
||||
className={clsx(styles.line, styles[line.kind])}
|
||||
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : rowHeight }}
|
||||
style={rowStyle(item.start)}
|
||||
>
|
||||
<Line line={line} marks={marksByLine.get(row.index)} length={line.text.length} cwd={cwd} />
|
||||
{row.fold && foldButton(row.fold, true)}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { ConsoleModel } from "./console";
|
||||
import { ConsoleView } from "./ConsoleView";
|
||||
|
||||
const clipboard = vi.hoisted(() => ({ writeText: vi.fn(async (_text: string) => {}) }));
|
||||
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
getTotalSize: () => count * 20,
|
||||
getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, start: index * 20, key: index })),
|
||||
scrollToIndex: () => {},
|
||||
measureElement: () => {},
|
||||
measure: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
usePlatform.setState({ os: "macos" });
|
||||
clipboard.writeText.mockClear();
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: clipboard });
|
||||
window.getSelection()?.removeAllRanges();
|
||||
});
|
||||
|
||||
function setup(text: string, softWrap = false) {
|
||||
const model = new ConsoleModel();
|
||||
model.append({ kind: "stdout", text });
|
||||
model.flush();
|
||||
const view = render(
|
||||
<ConsoleView model={model} version={1} softWrap={softWrap} follow={false} onFollowChange={() => {}} cwd="/" />,
|
||||
);
|
||||
return { model, log: screen.getByRole("log"), view };
|
||||
}
|
||||
|
||||
/** The text a Select All + Copy takes: every mounted row, in order. */
|
||||
function selectedText(log: HTMLElement) {
|
||||
const inner = log.firstElementChild!;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(inner);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return selection.toString();
|
||||
}
|
||||
|
||||
describe("Copying run output", () => {
|
||||
it("takes blank lines from the buffer, adding no zero-width space", () => {
|
||||
const { model, log } = setup("first\n\nsecond\n");
|
||||
expect(model.text()).toBe("first\n\nsecond");
|
||||
// A blank line is one empty row: it may only contribute its own line break.
|
||||
expect(selectedText(log).split("\n")).toEqual(["first", "", "second", ""]);
|
||||
expect(log.textContent).not.toContain("\u200b");
|
||||
});
|
||||
|
||||
it("keeps the zero-width space out of soft-wrapped output too", () => {
|
||||
const { log } = setup("first\n\nsecond\n", true);
|
||||
expect(selectedText(log).split("\n")).toEqual(["first", "", "second", ""]);
|
||||
expect(log.textContent).not.toContain("\u200b");
|
||||
});
|
||||
|
||||
it("still renders a row for every line, blank ones included", () => {
|
||||
const { model, log } = setup("first\n\n\nsecond\n");
|
||||
expect(model.lines).toHaveLength(4);
|
||||
expect(log.querySelectorAll("[data-index]")).toHaveLength(model.lines.length);
|
||||
expect(log.textContent?.replace(/\n/g, "")).toBe("first" + "second");
|
||||
});
|
||||
|
||||
it("puts the same text on the clipboard when a selection is copied with ⌘C", async () => {
|
||||
const { log } = setup("first\n\nsecond\n");
|
||||
// A selection of the rows, not Select All: that path copies the model and would hide a character
|
||||
// the DOM adds.
|
||||
const rows = log.querySelectorAll("[data-index]");
|
||||
const range = document.createRange();
|
||||
range.setStart(rows[0]!, 0);
|
||||
range.setEnd(rows[2]!, rows[2]!.childNodes.length);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
fireEvent.keyDown(log, { key: "c", code: "KeyC", metaKey: true });
|
||||
await waitFor(() => expect(clipboard.writeText).toHaveBeenCalledTimes(1));
|
||||
// Three rows, each ending in its own break: the blank one contributes nothing else.
|
||||
expect(clipboard.writeText.mock.calls[0]![0]).toBe("first\n\nsecond\n");
|
||||
});
|
||||
});
|
||||
@@ -287,6 +287,8 @@ describe("catalogs", () => {
|
||||
"CMake",
|
||||
"f-string",
|
||||
"Tailwind CSS",
|
||||
"Bun",
|
||||
"bun-dap (Bun)",
|
||||
]);
|
||||
const translated = SUPPORTED_LOCALES.filter((locale) => locale !== "en").sort();
|
||||
|
||||
|
||||
@@ -1344,5 +1344,7 @@
|
||||
"Close Agent Task": "Close Agent Task",
|
||||
"Close and New Agent Task": "Close and New Agent Task",
|
||||
"Closes the agent task tab in the editor; the task keeps running in the Agents tool window.": "Closes the agent task tab in the editor; the task keeps running in the Agents tool window.",
|
||||
"Closes the agent task tab in the editor, then opens the New Task page.": "Closes the agent task tab in the editor, then opens the New Task page."
|
||||
"Closes the agent task tab in the editor, then opens the New Task page.": "Closes the agent task tab in the editor, then opens the New Task page.",
|
||||
"May be incomplete: plugin indexes are still updating": "May be incomplete: plugin indexes are still updating",
|
||||
"Plugin indexes could not be updated; see the plugin notification": "Plugin indexes could not be updated; see the plugin notification"
|
||||
}
|
||||
@@ -7731,5 +7731,7 @@
|
||||
"Download Remote Files Into Folder": "将远程文件下载到文件夹",
|
||||
"Download Remote Host files into a local folder (arg: { server, paths, target })": "将远程主机中的文件下载到本地文件夹(参数:{ server, paths, target })",
|
||||
"Delegate IDE build/run actions to Maven": "将 IDE 构建/运行操作委托给 Maven",
|
||||
"Run and Debug build Maven modules with Maven (the project's javac) instead of the Java language server. Editor problems still come from the language server.": "运行和调试时用 Maven(项目的 javac)构建 Maven 模块,而不是用 Java 语言服务器。编辑器中的问题仍来自语言服务器。"
|
||||
"Run and Debug build Maven modules with Maven (the project's javac) instead of the Java language server. Editor problems still come from the language server.": "运行和调试时用 Maven(项目的 javac)构建 Maven 模块,而不是用 Java 语言服务器。编辑器中的问题仍来自语言服务器。",
|
||||
"May be incomplete: plugin indexes are still updating": "可能不完整:插件索引仍在更新",
|
||||
"Plugin indexes could not be updated; see the plugin notification": "插件索引更新失败,详见插件通知"
|
||||
}
|
||||
@@ -1825,16 +1825,23 @@ export function addContributedFileTemplate(t: import("@/platform/fileTemplates")
|
||||
return () => useContributedFileTemplates.setState((s) => ({ templates: s.templates.filter((x) => x !== t) }));
|
||||
}
|
||||
|
||||
/** Flush contributed document indexes before workspace usages or refactoring. */
|
||||
const documentIndexSync = new Set<(path: string) => Promise<void>>();
|
||||
export function registerDocumentIndexSync(sync: (path: string) => Promise<void>): () => void {
|
||||
/**
|
||||
* Flush contributed document indexes before workspace usages or refactoring. A refactoring
|
||||
* refuses (throws) while an index is incomplete; usages go ahead and resolve `false`, so the
|
||||
* results can say they may be incomplete.
|
||||
*/
|
||||
export type DocumentIndexPurpose = "usages" | "refactor";
|
||||
type DocumentIndexSync = (path: string, purpose: DocumentIndexPurpose) => Promise<boolean | void>;
|
||||
const documentIndexSync = new Set<DocumentIndexSync>();
|
||||
export function registerDocumentIndexSync(sync: DocumentIndexSync): () => void {
|
||||
documentIndexSync.add(sync);
|
||||
return () => {
|
||||
documentIndexSync.delete(sync);
|
||||
};
|
||||
}
|
||||
export async function synchronizeDocumentIndexes(path: string): Promise<void> {
|
||||
await Promise.all([...documentIndexSync].map((sync) => sync(path)));
|
||||
export async function synchronizeDocumentIndexes(path: string, purpose: DocumentIndexPurpose): Promise<boolean> {
|
||||
const complete = await Promise.all([...documentIndexSync].map((sync) => sync(path, purpose)));
|
||||
return complete.every((c) => c !== false);
|
||||
}
|
||||
|
||||
/** Font zoom for non-CodeMirror surfaces, owned and released by their features. */
|
||||
|
||||
Reference in new issue
Заблокувати користувача