Merge remote-tracking branch 'origin/main' into feat/remote-development

# Conflicts:
#	src-tauri/src/commands/build.rs
#	src-tauri/src/commands/debug.rs
#	src-tauri/src/commands/lsp.rs
#	src-tauri/src/commands/run.rs
#	src-tauri/src/commands/tools.rs
#	src-tauri/src/commands/vcs.rs
#	src-tauri/src/state.rs
#	src/bindings.ts
#	src/features/database/SourceDialog.tsx
#	src/features/index.ts
#	src/platform/locales/text/zh.json
#	src/styles/tokens.css
Цей коміт міститься в:
l0ng-ai committed 2026-10-27 19:33:02 +08:00
коміт e82d766804
381 files changed
+18610 -1111

No files matched your search

+267 -1
Переглянути файл
@@ -53,6 +53,27 @@ export const commands = {
cancelRequest: (id: RequestId) => __TAURI_INVOKE<void>("cancel_request", { id }),
/** Routes frontend logs into the backend `tracing` pipeline. */
logFrontend: (level: LogLevel, message: string) => __TAURI_INVOKE<void>("log_frontend", { level, message }),
/** Streams the project's agent events to this window; returns every task. */
agentSubscribe: (onEvent: Channel<AgentEvent>) => typedError<AgentTask[], IdeError>(__TAURI_INVOKE("agent_subscribe", { onEvent })),
agentProviders: () => typedError<AgentProvider[], IdeError>(__TAURI_INVOKE("agent_providers")),
agentEntries: (task: string) => typedError<AgentEntry[], IdeError>(__TAURI_INVOKE("agent_entries", { task })),
agentCreate: (request: NewAgentTask) => typedError<AgentTask, IdeError>(__TAURI_INVOKE("agent_create", { request })),
/** Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. */
agentPrompt: (task: string, prompt: AgentPrompt, now: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_prompt", { task, prompt, now })),
/** Drops a queued message and returns it (to edit it). */
agentUnqueue: (task: string, queued: string) => typedError<AgentQueued, IdeError>(__TAURI_INVOKE("agent_unqueue", { task, queued })),
/** Sends a queued message now, interrupting the current turn. */
agentSendQueuedNow: (task: string, queued: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_send_queued_now", { task, queued })),
/** Changes a session setting (model, mode, thinking effort…). */
agentSetConfig: (task: string, option: string, value: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_set_config", { task, option, value })),
agentCancel: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_cancel", { task })),
/** Answers a permission request; `option: null` cancels it. */
agentRespond: (task: string, permission: number, option: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_respond", { task, permission, option })),
agentArchive: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_archive", { task })),
agentDelete: (task: string, removeWorktree: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_delete", { task, removeWorktree })),
agentChanges: (task: string) => typedError<AgentChange[], IdeError>(__TAURI_INVOKE("agent_changes", { task })),
/** Content of `path` when the task started (left side of its diff). */
agentBaseText: (task: string, path: IpcPath) => typedError<string | null, IdeError>(__TAURI_INVOKE("agent_base_text", { task, path })),
fsReadDir: (path: string) => typedError<FileEntry[], IdeError>(__TAURI_INVOKE("fs_read_dir", { path })),
/** Size and text/binary classification, from the file's head only. */
fsProbe: (path: string) => typedError<FileProbe, IdeError>(__TAURI_INVOKE("fs_probe", { path })),
@@ -266,7 +287,12 @@ export const commands = {
* language server returned a non-`file:` URI for. Cached per project.
*/
lspVirtualDocument: (uri: string) => typedError<VirtualDocument, IdeError>(__TAURI_INVOKE("lsp_virtual_document", { uri })),
lspReferences: (doc: DocId, pos: Pos, includeDeclaration: boolean) => typedError<Location[], IdeError>(__TAURI_INVOKE("lsp_references", { doc, pos, includeDeclaration })),
/**
* References of the symbol at `pos`. `project_wide` also searches project files
* outside the language server's analysis scope; bulk scans over many symbols
* leave it off. Cancel with `cancel_request(request)`.
*/
lspReferences: (request: RequestId, doc: DocId, pos: Pos, includeDeclaration: boolean, projectWide: boolean) => typedError<References, IdeError>(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide })),
lspDocumentHighlights: (doc: DocId, pos: Pos) => typedError<Highlight[], IdeError>(__TAURI_INVOKE("lsp_document_highlights", { doc, pos })),
lspWorkspaceSymbols: (request: RequestId, query: string) => typedError<WorkspaceSymbol[], IdeError>(__TAURI_INVOKE("lsp_workspace_symbols", { request, query })),
lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError<CodeAction[], IdeError>(__TAURI_INVOKE("lsp_code_actions", { doc, range, only })),
@@ -452,6 +478,14 @@ export const commands = {
* Returns the refreshed model; a failed build is reported in its `syncError`.
*/
buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], onProgress: Channel<SyncProgress>) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })),
/** Where the direct dependency `dependency` of the Maven module `pom` is declared. */
mavenDependencyDeclaration: (pom: string, dependency: ArtifactKey) => typedError<Location, IdeError>(__TAURI_INVOKE("maven_dependency_declaration", { pom, dependency })),
/**
* Adds an `<exclusion>` of `exclusion` to the declaration of the direct dependency
* `dependency` of the Maven module `pom`, through the open document when there is one
* (saved). Returns the declaration; nothing changes when it already excludes it.
*/
mavenExcludeDependency: (pom: string, dependency: ArtifactKey, exclusion: ArtifactKey) => typedError<Location, IdeError>(__TAURI_INVOKE("maven_exclude_dependency", { pom, dependency, exclusion })),
/**
* Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the
* module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable
@@ -664,6 +698,11 @@ export const commands = {
pluginId: string,
theme: ThemeDef,
} | null, IdeError>(__TAURI_INVOKE("plugin_set_theme", { pluginId, themeId })),
/**
* The UI reports the selected plugin theme's resolved frame colour (`#rrggbb[aa]`),
* which new windows open in before their page loads.
*/
pluginSetThemeFrame: (color: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_set_theme_frame", { color })),
/**
* Snapshot of process memory and subsystem sizes, summed over all projects.
* Async: the process walk runs on the blocking pool, not the main thread.
@@ -880,6 +919,180 @@ export type ActiveTheme = {
theme: ThemeDef,
};
/** A file the task changed, relative to [`AgentTask::base_commit`]. */
export type AgentChange = {
path: IpcPath,
relPath: string,
kind: ChangeKind,
added: number,
removed: number,
};
export type AgentCommand = {
/** Without the leading `/`. */
name: string,
description: string,
/** What to type after the command, when it takes input. */
hint: string | null,
};
/** A setting chosen for a new task, by [`AgentConfigOption::id`]. */
export type AgentConfigChoice = {
id: string,
value: string,
};
/** A session setting: an ACP select config option, or the older `modes` / `models`. */
export type AgentConfigOption = {
id: string,
name: string,
description: string | null,
/** ACP category: `model`, `mode`, `thought_level`, or agent-specific. */
category: string | null,
current: string | null,
values: AgentConfigValue[],
via: AgentConfigVia,
};
/** One value of a select-type session setting. */
export type AgentConfigValue = {
value: string,
name: string,
description: string | null,
};
/** How a setting is changed. */
export type AgentConfigVia =
/** `session/set_config_option`. */
"option" |
/** `session/set_mode` (agents that report `modes` instead of a mode config option). */
"mode" |
/** `session/set_model` (agents that report `models` instead of a model config option). */
"model";
/** One item of a task's conversation. */
export type AgentEntry = { type: "user"; text: string;
/** Names of the files and images sent with it. */
attachments: string[] } | { type: "agent"; text: string } | { type: "thought"; text: string } | { type: "tool"; id: string; title: string;
/** ACP tool kind: `read`, `edit`, `execute`, `search`, `fetch`, `think`… */
kind: string; status: AgentToolStatus; locations: IpcPath[];
/** Text the tool reported (command output…), truncated. */
output: string | null; diffs: AgentToolDiff[] } | { type: "plan"; items: AgentPlanItem[] } | { type: "permission"; id: number; title: string; options: AgentPermissionOption[];
/** Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled". */
chosen: string | null } | { type: "error"; message: string };
/** Streamed to the window that subscribed (`AgentManager::subscribe`). */
export type AgentEvent =
/** A task was created or its metadata changed. */
{ type: "task"; task: AgentTask } | { type: "removed"; id: string } |
/** Entry `index` of `task` was added or replaced. */
{ type: "entry"; task: string; index: number; entry: AgentEntry };
export type AgentImage = {
mimeType: string,
/** Base64, without a `data:` prefix. */
data: string,
name: string | null,
};
export type AgentPermissionOption = {
id: string,
name: string,
/** `allow_once`, `allow_always`, `reject_once` or `reject_always`. */
kind: string,
};
export type AgentPlanItem = {
content: string,
/** `pending`, `in_progress` or `completed`. */
status: string,
};
/** A message to the agent: text, plus files to look at and images. */
export type AgentPrompt = {
text: string,
files: IpcPath[],
images: AgentImage[],
};
export type AgentProvider = {
id: string,
name: string,
/** The command line that starts its ACP server. */
command: string,
/** Whether the command was found (on `PATH`). */
installed: boolean,
/** Settings (models, modes…) the agent offered last time it ran; empty before its first session. */
config: AgentConfigOption[],
/** Slash commands it offered last time. */
commands: AgentCommand[],
};
export type AgentQueued = {
id: string,
prompt: AgentPrompt,
};
export type AgentStatus =
/** Creating the worktree / starting the agent. */
"starting" | "running" |
/** Waiting for a permission answer. */
"needsInput" |
/** The turn ended; the result can be reviewed or the conversation continued. */
"ready" | "failed" | "archived";
export type AgentTask = {
id: string,
title: string,
/** [`AgentProvider::id`]. */
provider: string,
/** Model the task runs on (its `model` setting), when known. */
model: string | null,
status: AgentStatus,
isolation: Isolation,
/** Directory the agent works in (the worktree, or the project root). */
cwd: IpcPath,
/** Branch of the task's worktree. */
branch: string | null,
/** Branch (or revision) the worktree was created from. */
base: string | null,
/** Commit the task's changes are compared against. */
baseCommit: string | null,
/** Why the task failed, when it did. */
error: string | null,
createdMs: number | null,
updatedMs: number | null,
/**
* The session's settings (model, mode, thinking effort…) as last reported, with the
* user's choices applied.
*/
config: AgentConfigOption[],
/** Slash commands the agent offers. */
commands: AgentCommand[],
/** Context window use, when the agent reports it. */
usage: AgentUsage | null,
/** Messages waiting for the current turn to end, oldest first. */
queue: AgentQueued[],
};
export type AgentToolDiff = {
path: IpcPath,
added: number,
removed: number,
};
export type AgentToolStatus = "pending" | "running" | "completed" | "failed";
export type AgentUsage = {
/** Tokens in the context window. */
used: number,
/** Size of the context window. */
size: number,
/** Session cost so far. */
cost: number | null,
currency: string | null,
};
export type AppInfo = {
version: string,
platform: string,
@@ -961,6 +1174,12 @@ export type ApplyOutcome = {
conflicts: string[],
};
/** `groupId:artifactId` of a dependency. */
export type ArtifactKey = {
groupId: string,
artifactId: string,
};
/** What an askpass prompt asks for. */
export type AskpassKind =
/** Password or passphrase: hidden input. */
@@ -3045,6 +3264,12 @@ export type InvalidateCachesOptions = {
/** A path field of an IPC type. */
export type IpcPath = string;
export type Isolation =
/** A new git worktree on its own branch. */
"worktree" |
/** The project's own checkout. */
"local";
export type IssueLink = {
/** Regular expression matched in commit messages. */
pattern?: string,
@@ -3436,6 +3661,12 @@ export type MavenDependency = {
optional: boolean,
/** Transitive dependencies (only after a sync). */
children: MavenDependency[],
/** Why Maven left this occurrence out of the resolved graph (`dependency:tree -Dverbose`). */
omitted?: Omission | null,
/** The version that won when `omitted` is `Conflict`. */
conflictWith?: string | null,
/** The version requested before `<dependencyManagement>` replaced it. */
managedFrom?: string | null,
};
export type MavenModel = {
@@ -3589,6 +3820,16 @@ export type MethodStat = {
total: number | null,
};
export type NewAgentTask = {
prompt: AgentPrompt,
provider: string,
/** Settings for the new session (model, mode…). */
config: AgentConfigChoice[],
isolation: Isolation,
/** Branch to start the worktree from; the current branch when `None`. */
base: string | null,
};
/** Result of `node_at`: the smallest named node covering a range, with its ancestors. */
export type NodeAt = {
node: SyntaxNode,
@@ -3637,6 +3878,15 @@ export type NotificationSettings = {
export type NotifyLevel = "info" | "warning" | "error";
/** Why a dependency occurrence is not part of the resolved graph. */
export type Omission =
/** Another occurrence of the same version is nearer the root. */
"duplicate" |
/** Another version is nearer the root (or declared first) and wins. */
"conflict" |
/** The artifact depends on itself through this path. */
"cycle";
/** How `open_project_window` satisfied the request. */
export type OpenOutcome =
/** Already open in `label`; that window was focused. */
@@ -4099,6 +4349,18 @@ export type RecentProject = {
branch: string | null,
};
export type References = {
locations: Location[],
incomplete: ReferencesIncomplete | null,
};
/** Why a references search may have missed usages. */
export type ReferencesIncomplete =
/** A language server was still indexing. */
"indexing" |
/** More project files mention the name than were checked. */
"tooManyFiles";
export type Remote = {
name: string,
/** Fetch URL. */
@@ -5220,6 +5482,10 @@ export type VcsRoot = {
root: IpcPath,
/** Merge / rebase / cherry-pick / revert in progress. */
operation: string | null,
/** Current branch; `None` when HEAD is detached (or unreadable). */
head: string | null,
/** HEAD commit; `None` before the first commit. */
headCommit: string | null,
};
/** Version Control (main page) and its sub-pages. */
+19 -2
Переглянути файл
@@ -37,7 +37,7 @@ import { qualifiedReference } from "./copyReference";
import { symbolChain } from "./view/outline";
import { recordClipboard } from "./clipboardHistory";
import { useDocMeta, useOutline } from "./model/document";
import { fontZoom, setFontZoom } from "./cm/prefs";
import { fontZoom, setFontZoom, stepFontZoom } from "./cm/prefs";
import { useEditorRegistry } from "@/editor/api";
import { useProject } from "@/platform/project";
import { call, commands } from "@/platform/ipc";
@@ -54,6 +54,7 @@ import {
} from "./cm/structural";
import type { DocModel } from "./model/document";
import { structure } from "@/editor/structure";
import { tx } from "@/platform/i18n";
/** Text-editing actions: the editor has focus, or a popup (Find Action) runs them on it. */
export const TEXT_WHEN = "editorTextFocus || textEditorActive && popupOpen";
@@ -293,7 +294,7 @@ function copyReference(view: EditorView, slot: EditorSlot) {
});
recordClipboard(reference);
void navigator.clipboard?.writeText(reference).catch(() => {});
void executeAction("editor.showHint", { text: `Copied reference: ${reference}` });
void executeAction("editor.showHint", { text: tx`Copied reference: ${reference}` });
}
/** ⌃G; when nothing more is found, a hint first and the search from the top on the next press (IDEA). */
@@ -471,6 +472,22 @@ export function registerEditorActions(): () => void {
if (ed.view.state.field(fontZoom, false)) ed.view.dispatch({ effects: setFontZoom.of(0) });
},
},
{
id: "editor.increaseFontSize",
title: "Increase Font Size",
category: "View",
keywords: ["zoom", "font size"],
when: EDITOR_WHEN,
run: withView((view) => stepFontZoom(view, 1)),
},
{
id: "editor.decreaseFontSize",
title: "Decrease Font Size",
category: "View",
keywords: ["zoom", "font size"],
when: EDITOR_WHEN,
run: withView((view) => stepFontZoom(view, -1)),
},
{
id: "file.toggleReadOnly",
title: "Toggle Read-only Attribute",
+2 -1
Переглянути файл
@@ -5,6 +5,7 @@
*/
import { EditorView } from "@codemirror/view";
import type { EditorState } from "@codemirror/state";
import { tx } from "@/platform/i18n";
export const MAX_CLIPBOARD_ENTRIES = 30;
@@ -45,7 +46,7 @@ export function clipLabel(text: string, max = 80): { label: string; detail: stri
const first = (lines.find((l) => l.trim()) ?? "").trim();
const label = first.length > max ? `${first.slice(0, max - 1)}…` : first || "(whitespace)";
const count = text.endsWith("\n") ? lines.length - 1 : lines.length;
return { label, detail: count > 1 ? `${count} lines` : undefined };
return { label, detail: count > 1 ? tx`${count} lines` : undefined };
}
export const clipboardHistoryExtension = EditorView.domEventHandlers({
+11 -4
Переглянути файл
@@ -343,8 +343,9 @@ export const setFontZoom = StateEffect.define<number>();
export const fontZoom = StateField.define<number>({
create: () => 0,
update: (v, tr) => tr.effects.reduce((acc, e) => (e.is(setFontZoom) ? e.value : acc), v),
// Highest precedence: its style comes after the configured font size, which it scales.
provide: (f) =>
Prec.lowest(
Prec.highest(
EditorView.editorAttributes.compute([f], (s) => {
const z = s.field(f);
const attrs: Record<string, string> = {};
@@ -355,13 +356,19 @@ export const fontZoom = StateField.define<number>({
),
});
/** Steps an editor's font zoom by `delta` px, kept within -6..36. */
export function stepFontZoom(view: EditorView, delta: number): void {
const z = view.state.field(fontZoom, false);
if (z === undefined) return;
const next = Math.max(-6, Math.min(36, z + delta));
if (next !== z) view.dispatch({ effects: setFontZoom.of(next) });
}
const wheelZoom = EditorView.domEventHandlers({
wheel(e, view) {
if (!e.metaKey && !e.ctrlKey) return false;
e.preventDefault();
const z = view.state.field(fontZoom, false) ?? 0;
const next = Math.max(-6, Math.min(36, z + (e.deltaY < 0 ? 1 : -1)));
if (next !== z) view.dispatch({ effects: setFontZoom.of(next) });
stepFontZoom(view, e.deltaY < 0 ? 1 : -1);
return true;
},
});
+2 -1
Переглянути файл
@@ -12,6 +12,7 @@ import { openideaHighlightStyle } from "./highlight";
import { openideaTheme } from "./theme";
import { findLanguageDescription, loadSupport, loadedSupport, uiModeForPath } from "./languages";
import { fontExtension } from "./setup";
import { tx } from "@/platform/i18n";
export async function previewExtensions(path: string, settings: EditorSettings | undefined): Promise<Extension> {
const open = editorsFor(path)[0]?.ctx;
@@ -25,7 +26,7 @@ export async function previewExtensions(path: string, settings: EditorSettings |
syntaxHighlighting(openideaHighlightStyle),
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.contentAttributes.of({ "aria-label": `Preview of ${path}` }),
EditorView.contentAttributes.of({ "aria-label": tx`Preview of ${path}` }),
support ?? [],
];
}
+2 -1
Переглянути файл
@@ -51,6 +51,7 @@ import { gutterLineDrag } from "./gutterDrag";
import { overwriteField } from "./overwrite";
import { clipboardHistoryExtension } from "../clipboardHistory";
import type { LanguageInfo } from "../backend";
import { tx } from "@/platform/i18n";
export const compartments = {
language: new Compartment(),
@@ -309,7 +310,7 @@ export function baseExtensions(o: SetupOptions): Extension {
findExtension(),
overviewLane,
codeLensExtension,
EditorView.contentAttributes.of({ spellcheck: "false", autocorrect: "off", autocapitalize: "off", "aria-label": `Editor for ${o.ctx.path}` }),
EditorView.contentAttributes.of({ spellcheck: "false", autocorrect: "off", autocapitalize: "off", "aria-label": tx`Editor for ${o.ctx.path}` }),
tabKeymap,
keymap.of([...closeBracketsKeymap, ...editorBaseKeymap, indentWithTab]),
compartments.language.of(o.language ?? []),
+2 -1
Переглянути файл
@@ -5,6 +5,7 @@
* every language with a grammar is covered.
*/
import type { Text } from "@codemirror/state";
import { tx } from "@/platform/i18n";
export interface ChainNode {
kind: string;
@@ -133,7 +134,7 @@ export function unwrapOptions(doc: Text, chain: readonly ChainNode[]): UnwrapOpt
const key = `${target.from}:${target.to}:${label}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ label: `Unwrap '${label}'`, target: { from: target.from, to: target.to }, ...replacement(doc, target, child) });
out.push({ label: tx`Unwrap '${label}'`, target: { from: target.from, to: target.to }, ...replacement(doc, target, child) });
}
return out;
}
+4 -1
Переглянути файл
@@ -26,7 +26,10 @@ export function installColorScheme(): () => void {
if (s.settings !== prev.settings) apply();
});
const observer = typeof MutationObserver !== "undefined" ? new MutationObserver(apply) : null;
observer?.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-contrast"] });
observer?.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-contrast", "data-color-theme", "data-plugin-theme"],
});
return () => {
unsubscribe();
observer?.disconnect();
+58 -2
Переглянути файл
@@ -21,11 +21,13 @@ import {
menuGroupItems,
notifications,
resolveFileEditor,
useContributions,
type EditorInput,
type OpenOptions,
type OpenEditorTab,
} from "@/workbench/contributions";
import { useDistractionFree } from "@/workbench/distractionFree";
import { showContextMenu, type MenuItem } from "@/ui";
import { fileIconName, showContextMenu, type MenuItem } from "@/ui";
import { usePlatform } from "@/platform/platformInfo";
import {
activateTab,
@@ -140,6 +142,32 @@ function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: s
return !!tab && isTextInput(tab.input);
}
type EditorKinds = ReturnType<typeof useContributions.getState>["editorKinds"];
/** Tab title of an editor input: the file name, or what its editor kind calls it. */
export function tabTitle(input: EditorInput, kinds: EditorKinds): string {
if (input.kind === "file") return basename(input.path ?? input.key);
if (input.kind === "virtual")
return (input.data as VirtualInputData | undefined)?.title ?? basename(input.path ?? input.key);
const kind = kinds.get(input.kind);
try {
return kind?.title(input) ?? input.key;
} catch {
return input.key;
}
}
export function tabIcon(input: EditorInput, kinds: EditorKinds): string {
if (input.kind === "file") return fileIconName(basename(input.path ?? ""));
if (input.kind === "virtual") return fileIconName(tabTitle(input, kinds));
const kind = kinds.get(input.kind);
try {
return kind?.icon?.(input) ?? "file";
} catch {
return "file";
}
}
export class EditorController implements SlotHost {
manager: DocumentManager;
readonly slots = new Map<string, EditorSlot>();
@@ -157,6 +185,9 @@ export class EditorController implements SlotHost {
private restoredViews = new Map<string, FileViewState>();
/** Tabs created by session restore; dropped silently if their file is gone. */
private restoredTabs = new Set<string>();
/** When each input key was last the active tab (a counter, across panes). */
private lastActive = new Map<string, number>();
private activeClock = 0;
constructor(backend: DocBackend = docBackend(), scheduler?: Scheduler) {
this.manager = new DocumentManager(
@@ -178,6 +209,7 @@ export class EditorController implements SlotHost {
useTabs.subscribe((s) => {
const prev = prevTabs;
prevTabs = s;
this.stampActive(s);
this.onTabsChanged(prev, s);
}),
);
@@ -301,7 +333,8 @@ export class EditorController implements SlotHost {
if (files[path] && slot.model.lastFocused !== slot && !this.restoredViews.has(path)) continue;
files[path] = slot.viewState();
}
storeSession(root, serializeSession(useTabs.getState(), files));
const kinds = useContributions.getState().editorKinds;
storeSession(root, serializeSession(useTabs.getState(), files, (input) => !!kinds.get(input.kind)?.restorable));
}
/** Reopens the tabs, splits and carets saved for `root` (after the project opened). */
@@ -993,6 +1026,29 @@ export class EditorController implements SlotHost {
pane?.querySelector<HTMLElement>("[data-editor-focus], .cm-content")?.focus({ preventScroll: true });
}
private stampActive(s: TabsState) {
const pane = s.panes[s.activePane];
const key = pane?.tabs.find((t) => t.id === pane.active)?.input.key;
if (key && this.lastActive.get(key) !== this.activeClock) this.lastActive.set(key, ++this.activeClock);
}
/** Every open tab once (all panes), most recently active first; tabs never active this session follow in tab order. */
openTabs(): OpenEditorTab[] {
const s = useTabs.getState();
const kinds = useContributions.getState().editorKinds;
const out = new Map<string, OpenEditorTab>();
for (const id of paneIds(s))
for (const t of s.panes[id]?.tabs ?? [])
if (!out.has(t.input.key))
out.set(t.input.key, {
input: t.input,
title: tabTitle(t.input, kinds),
icon: tabIcon(t.input, kinds),
lastActive: this.lastActive.get(t.input.key) ?? 0,
});
return [...out.values()].sort((a, b) => b.lastActive - a.lastActive);
}
openPaths(): string[] {
const s = useTabs.getState();
const out = new Set<string>();
+3 -2
Переглянути файл
@@ -8,11 +8,12 @@ import { basename } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { confirm } from "@/workbench/messages";
import type { DocModel } from "./model/document";
import { tx } from "@/platform/i18n";
export function readOnlyQuestion(name: string, readOnly: boolean) {
return readOnly
? { title: "Make File Writable", message: `'${name}' is read-only. Make it writable?`, confirm: "Make Writable" }
: { title: "Make File Read-only", message: `Make '${name}' read-only?`, confirm: "Make Read-only" };
? { title: "Make File Writable", message: tx`'${name}' is read-only. Make it writable?`, confirm: "Make Writable" }
: { title: "Make File Read-only", message: tx`Make '${name}' read-only?`, confirm: "Make Read-only" };
}
/** Applies the toggle without asking. Returns the new read-only state, or null on failure. */
+1
Переглянути файл
@@ -42,6 +42,7 @@ export function activate(): void | (() => void) {
saveAll: () => ctrl.saveAll(),
focus: () => ctrl.focus(),
openPaths: () => ctrl.openPaths(),
openTabs: () => ctrl.openTabs(),
closePath: (path) => ctrl.closePath(path),
closeInput: (key) => ctrl.closeInput(key),
reformat: async (path) => {
+2 -1
Переглянути файл
@@ -44,6 +44,7 @@ import {
} from "../cm/setup";
import { setMethodLines, softWrapActive } from "../cm/prefs";
import type { EditorSlot } from "./slot";
import { tx } from "@/platform/i18n";
export type Banner = "binary" | "tooLarge" | "readOnly" | "library" | null;
@@ -237,7 +238,7 @@ export class DocModel {
manager.notify({
level: "warning",
title: "Editor resynchronised",
body: `Some unsaved edits to ${this.path} could not be applied and were replaced by the file's current content.`,
body: tx`Some unsaved edits to ${this.path} could not be applied and were replaced by the file's current content.`,
}),
onError: (e) => console.warn(`document ${this.path}: sync error`, e),
},
+25
Переглянути файл
@@ -39,6 +39,31 @@ describe("editor session", () => {
expect(r.layout).toMatchObject({ kind: "split", dir: "column" });
});
it("saves tabs of restorable editor kinds with their input", () => {
let s = initialTabsState();
s = open(s, "/p/a.ts");
s = openTab(s, s.activePane, { kind: "agent-session", key: "agent:1", data: { task: "1" } }).state;
s = openTab(s, s.activePane, { kind: "diff", key: "diff:x", data: { big: true } }).state;
s = openTab(s, s.activePane, { kind: "agent-session", key: "agent:1", data: { task: "1" } }).state;
const restorable = (i: { kind: string }) => i.kind === "agent-session";
const session = serializeSession(s, {}, restorable)!;
expect(session.layout).toMatchObject({ kind: "pane", active: 1 });
const r = restoreTabsState(parseSession(JSON.parse(JSON.stringify(session)))!, fileKey);
const pane = r.panes[paneIds(r)[0]!]!;
expect(pane.tabs.map((t) => t.input)).toEqual([file("/p/a.ts"), { kind: "agent-session", key: "agent:1", data: { task: "1" } }]);
expect(pane.active).toBe(pane.tabs[1]!.id);
// Only restorable tabs: still a session. Junk inputs (file kind, no key) are dropped.
const empty = initialTabsState();
const only = openTab(empty, empty.activePane, { kind: "agent-session", key: "agent:2", data: {} }).state;
expect(serializeSession(only, {}, restorable)).not.toBeNull();
expect(serializeSession(only, {})).toBeNull();
const junk = parseSession({
version: 1,
layout: { kind: "pane", tabs: [{ input: { kind: "file", key: "k" } }, { input: { kind: "x" } }, { input: { kind: "x", key: "y" } }], active: 0 },
})!;
expect(junk.layout).toEqual({ kind: "pane", tabs: [{ input: { kind: "x", key: "y" } }], active: 0 });
});
it("returns null for sessions without file tabs and rejects junk", () => {
expect(serializeSession(initialTabsState(), {})).toBeNull();
expect(parseSession({ version: 2 })).toBeNull();
+45 -17
Переглянути файл
@@ -1,14 +1,19 @@
/**
* Editor session per project (IDEA's workspace.xml editor state): open file
* tabs with pinned / preview flags and the active tab of every pane, the split
* layout, the active pane and caret / scroll position per file. Serialised to
* layout, the active pane and caret / scroll position per file. Tabs of editor
* kinds that ask for it (`EditorKindContribution.restorable`, like IDEA's
* FileEditorProvider state) are saved too, as their whole input. Serialised to
* plain JSON (localStorage, keyed by project root) and rebuilt into a fresh
* TabsState with new ids.
*/
import type { EditorInput } from "@/workbench/contributions";
import { initialTabsState, paneIds, type LayoutNode, type Pane, type Tab, type TabsState } from "./tabs";
/** A file tab (`path`), or a tab of a restorable editor kind (`input`, with JSON `data`). */
export interface SessionTab {
path: string;
path?: string;
input?: EditorInput;
pinned?: boolean;
preview?: boolean;
}
@@ -34,18 +39,26 @@ export interface EditorSession {
const STORAGE_PREFIX = "openidea.editorSession.v1:";
export function serializeSession(s: TabsState, files: Record<string, FileViewState>): EditorSession | null {
const isFileTab = (t: Tab) => t.input.kind === "file" && !!t.input.path;
/** `restorable`: whether a non-file tab is saved (its kind asked for it). */
export function serializeSession(
s: TabsState,
files: Record<string, FileViewState>,
restorable: (input: EditorInput) => boolean = () => false,
): EditorSession | null {
const saved = (t: Tab) => isFileTab(t) || (t.input.kind !== "file" && restorable(t.input));
const walk = (n: LayoutNode): SessionLayout | null => {
if (n.kind === "pane") {
const pane = s.panes[n.paneId];
if (!pane) return null;
const fileTabs = pane.tabs.filter((t) => t.input.kind === "file" && !!t.input.path);
if (!fileTabs.length) return null;
const active = Math.max(0, fileTabs.findIndex((t) => t.id === pane.active));
const kept = pane.tabs.filter(saved);
if (!kept.length) return null;
const active = Math.max(0, kept.findIndex((t) => t.id === pane.active));
return {
kind: "pane",
tabs: fileTabs.map((t) => ({
path: t.input.path!,
tabs: kept.map((t) => ({
...(isFileTab(t) ? { path: t.input.path! } : { input: { ...t.input } }),
...(t.pinned ? { pinned: true } : {}),
...(t.preview ? { preview: true } : {}),
})),
@@ -59,10 +72,11 @@ export function serializeSession(s: TabsState, files: Record<string, FileViewSta
const layout = walk(s.layout);
if (!layout) return null;
// Active pane index among panes that survived serialisation.
const kept = paneIds(s).filter((id) => s.panes[id]?.tabs.some((t) => t.input.kind === "file" && t.input.path));
const kept = paneIds(s).filter((id) => s.panes[id]?.tabs.some(saved));
const activePane = Math.max(0, kept.indexOf(s.activePane));
const paths = new Set<string>();
const collect = (l: SessionLayout) => (l.kind === "pane" ? l.tabs.forEach((t) => paths.add(t.path)) : l.children.forEach(collect));
const collect = (l: SessionLayout) =>
l.kind === "pane" ? l.tabs.forEach((t) => t.path && paths.add(t.path)) : l.children.forEach(collect);
collect(layout);
const keptFiles: Record<string, FileViewState> = {};
for (const p of paths) if (files[p]) keptFiles[p] = files[p]!;
@@ -78,9 +92,15 @@ export function slashPath(p: string): string {
}
function isSessionTab(v: unknown): v is SessionTab {
return !!v && typeof v === "object" && typeof (v as SessionTab).path === "string" && !!(v as SessionTab).path;
if (!v || typeof v !== "object") return false;
const t = v as SessionTab;
if (typeof t.path === "string") return !!t.path;
const i = t.input as Partial<EditorInput> | undefined;
return !!i && typeof i.kind === "string" && i.kind !== "file" && typeof i.key === "string" && !!i.key;
}
const tabKey = (t: SessionTab) => (t.path !== undefined ? `file:${t.path}` : `input:${t.input!.key}`);
/** Validates untrusted JSON; returns null when unusable. */
export function parseSession(raw: unknown): EditorSession | null {
if (!raw || typeof raw !== "object") return null;
@@ -90,7 +110,9 @@ export function parseSession(raw: unknown): EditorSession | null {
if (!l || typeof l !== "object" || depth > 16) return null;
const n = l as SessionLayout;
if (n.kind === "pane") {
const tabs = Array.isArray(n.tabs) ? n.tabs.filter(isSessionTab).map((t) => ({ ...t, path: slashPath(t.path) })) : [];
const tabs = Array.isArray(n.tabs)
? n.tabs.filter(isSessionTab).map((t) => (t.path !== undefined ? { ...t, path: slashPath(t.path) } : t))
: [];
if (!tabs.length) return null;
const active = typeof n.active === "number" && n.active >= 0 && n.active < tabs.length ? Math.floor(n.active) : 0;
return { kind: "pane", tabs, active };
@@ -128,17 +150,23 @@ export function restoreTabsState(session: EditorSession, fileKey: (path: string)
const seen = new Set<string>();
const tabs: Tab[] = [];
let preview = false;
const byKey = new Map<string, Tab>();
for (const t of l.tabs) {
if (seen.has(t.path)) continue;
seen.add(t.path);
const key = tabKey(t);
if (seen.has(key)) continue;
seen.add(key);
// At most one preview tab per pane; pinned tabs first.
const isPreview: boolean = !!t.preview && !t.pinned && !preview;
preview ||= isPreview;
tabs.push({ id: id("tab"), input: { kind: "file", key: fileKey(t.path), path: t.path }, pinned: !!t.pinned, preview: isPreview });
const input: EditorInput =
t.path !== undefined ? { kind: "file", key: fileKey(t.path), path: t.path } : { ...t.input! };
const tab: Tab = { id: id("tab"), input, pinned: !!t.pinned, preview: isPreview };
byKey.set(key, tab);
tabs.push(tab);
}
tabs.sort((a, b) => Number(b.pinned) - Number(a.pinned));
const activePath = l.tabs[l.active]?.path;
const active = tabs.find((t) => t.input.path === activePath) ?? tabs[0]!;
const activeTab = l.tabs[l.active];
const active = (activeTab && byKey.get(tabKey(activeTab))) ?? tabs[0]!;
panes[paneId] = { id: paneId, tabs, active: active.id, mru: [active.id, ...tabs.filter((t) => t !== active).map((t) => t.id)] };
order.push(paneId);
return { kind: "pane", paneId };
+3 -25
Переглянути файл
@@ -31,12 +31,11 @@ import {
SplitHandle,
SplitPanel,
Tabs,
fileIconName,
showContextMenu,
type TabItem,
type TabsDnd,
} from "@/ui";
import { editorController, isTextInput, useTabs, type VirtualInputData } from "../controller";
import { editorController, isTextInput, tabIcon, tabTitle, useTabs, type VirtualInputData } from "../controller";
import { useDocMeta } from "../model/document";
import type { LayoutNode, SplitEdge, Tab } from "../model/tabs";
import { disambiguateTitles } from "../model/tabTitles";
@@ -45,6 +44,7 @@ import { FindBar } from "./FindBar";
import { EditorBanners, VirtualBanner } from "./Banner";
import { CodeBreadcrumbs } from "./StatusItems";
import styles from "./EditorArea.module.css";
import { tx } from "@/platform/i18n";
export function EditorAreaView() {
const layout = useTabs((s) => s.layout);
@@ -115,28 +115,6 @@ function useTitleHints(): Map<string, string> {
const virtualData = (input: EditorInput) => input.data as VirtualInputData | undefined;
function tabTitle(input: EditorInput, kinds: ReturnType<typeof useContributions.getState>["editorKinds"]): string {
if (input.kind === "file") return basename(input.path ?? input.key);
if (input.kind === "virtual") return virtualData(input)?.title ?? basename(input.path ?? input.key);
const kind = kinds.get(input.kind);
try {
return kind?.title(input) ?? input.key;
} catch {
return input.key;
}
}
function tabIcon(input: EditorInput, kinds: ReturnType<typeof useContributions.getState>["editorKinds"]): string {
if (input.kind === "file") return fileIconName(basename(input.path ?? ""));
if (input.kind === "virtual") return fileIconName(tabTitle(input, kinds));
const kind = kinds.get(input.kind);
try {
return kind?.icon?.(input) ?? "file";
} catch {
return "file";
}
}
function PaneView({ paneId }: { paneId: string }) {
const ctrl = editorController();
const pane = useTabs((s) => s.panes[paneId]);
@@ -366,7 +344,7 @@ function PaneContent({ paneId, tab }: { paneId: string; tab: Tab }) {
);
}
const kind = kinds.get(tab.input.kind);
if (!kind) return <EmptyState icon="file" title={`No editor for “${tab.input.kind}”`} />;
if (!kind) return <EmptyState icon="file" title={tx`No editor for “${tab.input.kind}”`} />;
const Component = kind.component;
return (
<div className={styles.kind} data-editor-focus="" tabIndex={-1} key={tab.id}>
+6 -3
Переглянути файл
@@ -9,13 +9,14 @@ import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { codeLenses, type CodeLens } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { commands, type OutlineItem, type SymbolKind } from "@/platform/ipc";
import { commands, nextRequestId, type OutlineItem, type SymbolKind } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
import { posToOffset } from "./positions";
import { onLspRefresh, serverBackoff } from "./availability";
import { editorPrefs } from "@/platform/editorSettings";
import { authorLabel, authorSummary, authorTooltip, blameFor, onBlameInvalidated } from "./authorLens";
import { tx } from "@/platform/i18n";
const LENS_KINDS = new Set<SymbolKind>([
"class",
@@ -166,7 +167,9 @@ class UsagesLensPlugin {
await Promise.all(
todo.slice(i, i + CONCURRENCY).map(async (t) => {
try {
const refs = await lspRequest(() => commands.lspReferences(doc.doc, { line: t.line, col: t.col }, false));
const { locations: refs } = await lspRequest(() =>
commands.lspReferences(nextRequestId(), doc.doc, { line: t.line, col: t.col }, false, true),
);
this.cache.set(key(t), refs.length);
} catch (e) {
if (isUnavailable(e)) unavailable = true;
@@ -188,7 +191,7 @@ class UsagesLensPlugin {
lenses.push({
pos: offset,
title: usagesLabel(n),
tooltip: n ? `Show usages of ${t.name}` : undefined,
tooltip: n ? tx`Show usages of ${t.name}` : undefined,
order: 10,
run: n ? (v) => openUsages(v, Math.min(offset, v.state.doc.length)) : undefined,
});
+6
Переглянути файл
@@ -26,6 +26,12 @@ describe("semanticRanges", () => {
{ from: 34, to: 37, className: "cm-sem-constant" },
]);
});
it("applies the Python-specific static style without changing other languages", () => {
const input = { tokenTypes: ["property"], tokenModifiers: ["static"], data: [0, 0, 5, 0, 1] };
expect(semanticRanges(doc, input, false, "python")[0]?.className).toBe("cm-sem-property cm-sem-static cm-sem-python-static");
expect(semanticRanges(doc, input, false, "java")[0]?.className).toBe("cm-sem-property cm-sem-static");
});
});
describe("semantic highlighting (rainbow identifiers)", () => {
+2 -1
Переглянути файл
@@ -14,6 +14,7 @@ import { call, commands, isTauri, onIdeEvent, type PathCompletion } from "@/plat
import { fileIconName, folderIconName } from "@/ui";
import { setFileDiagnostics } from "./diagnostics";
import { docOf, lspDoc } from "./lsp";
import { tx } from "@/platform/i18n";
// --- completion ---------------------------------------------------------------
@@ -118,7 +119,7 @@ export function unresolvedReferences(): Extension {
refs.map((r) => ({
range: r.range,
severity: "warning",
message: `Cannot resolve file '${r.value}'`,
message: tx`Cannot resolve file '${r.value}'`,
source: doc.language === "html" || doc.language === "vue" || doc.language === "svelte" ? "HTML" : "CSS",
code: null,
})),
+1 -1
Переглянути файл
@@ -161,7 +161,7 @@ export async function reformatDialog(
const name = ctx.path.slice(ctx.path.lastIndexOf("/") + 1);
openPopup({
kind: "reformat",
title: `Reformat File: ${name}`,
title: tx`Reformat File: ${name}`,
hasSelection: !sel.empty,
canOptimizeImports: !contributedAt(view.state, CONTRIBUTED_FORMAT, 0),
initial: lastOptions,
+41 -1
Переглянути файл
@@ -4,13 +4,16 @@ import { EditorView } from "@codemirror/view";
import type { InlayHint } from "@/bindings";
const openFile = vi.fn(async () => undefined);
const gotoTargets = vi.fn(async () => undefined);
vi.mock("./navigation", () => ({ gotoTargets }));
vi.mock("@/workbench/contributions", async (orig) => {
const actual = await orig<typeof import("@/workbench/contributions")>();
return { ...actual, maybeEditorService: () => ({ openFile }) };
});
const { inlayDecorations, inlayMenuItems } = await import("./inlay");
const { inlayDecorations, inlayExtension, inlayMenuItems } = await import("./inlay");
const { usePlatform } = await import("@/platform/platformInfo");
const { resetNavigationModifier } = await import("@/platform/navigationModifier");
const { useContextMenuStore } = await import("@/ui");
const hint: InlayHint = {
@@ -33,6 +36,7 @@ const hint: InlayHint = {
const views: EditorView[] = [];
afterEach(() => {
views.splice(0).forEach((v) => v.destroy());
resetNavigationModifier();
vi.clearAllMocks();
});
@@ -71,4 +75,40 @@ describe("inlay hints", () => {
]);
expect((inlayMenuItems("parameter")[0] as { label: string }).label).toBe("Disable Parameter name hints");
});
it("uses type declaration navigation for a plain type hint after an identifier", () => {
usePlatform.setState({ os: "macos" });
const view = editorWith([{ ...hint, label: ": Foo", parts: [] }]);
const el = view.dom.querySelector<HTMLElement>(".cm-lsp-inlay")!;
el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
expect(gotoTargets).not.toHaveBeenCalled();
el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, metaKey: true }));
expect(gotoTargets).toHaveBeenCalledWith(view, "typeDefinition", 4);
expect(openFile).not.toHaveBeenCalled();
});
it("does not treat parameter hints or type hints away from an identifier as type links", () => {
const view = editorWith([
{ ...hint, kind: "parameter", parts: [] },
{ ...hint, position: { line: 0, col: 6 }, parts: [] },
]);
expect(view.dom.querySelectorAll(".cm-lsp-inlay-part")).toHaveLength(0);
});
it.each([
{ os: "macos", key: "Meta", modifier: "metaKey" },
{ os: "windows", key: "Control", modifier: "ctrlKey" },
] as const)("tracks the navigation modifier for inlay cursors on $os", ({ os, key, modifier }) => {
usePlatform.setState({ os });
const view = new EditorView({
state: EditorState.create({ doc: "let x = foo();", extensions: [inlayExtension()] }),
parent: document.body,
});
views.push(view);
expect(view.contentDOM.classList.contains("cm-lsp-navigation-modifier")).toBe(false);
window.dispatchEvent(new KeyboardEvent("keydown", { key, [modifier]: true }));
expect(view.contentDOM.classList.contains("cm-lsp-navigation-modifier")).toBe(true);
window.dispatchEvent(new KeyboardEvent("keyup", { key }));
expect(view.contentDOM.classList.contains("cm-lsp-navigation-modifier")).toBe(false);
});
});
+32 -2
Переглянути файл
@@ -8,7 +8,7 @@ import { StateEffect, StateField, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { call, commands, isTauri, type InlayHint } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { isNavigationGesture } from "@/platform/navigationModifier";
import { isNavigationGesture, navigationModifierHeld, onNavigationModifier } from "@/platform/navigationModifier";
import { maybeEditorService } from "@/workbench/contributions";
import { showContextMenu, type MenuItem } from "@/ui";
import { useProject } from "@/platform/project";
@@ -16,6 +16,8 @@ import { editorPrefs } from "@/platform/editorSettings";
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
import { offsetsToRange, posToOffset } from "./positions";
import { onLspRefresh, serverBackoff } from "./availability";
import { gotoTargets } from "./navigation";
import { tx } from "@/platform/i18n";
const KIND_LABEL: Record<InlayHint["kind"], string> = {
type: "Type hints",
@@ -28,10 +30,17 @@ async function setEditorSetting(key: string, value: unknown) {
useProject.setState({ settings: await call(commands.settingsSet("global", `editor.${key}`, JSON.stringify(value))) });
}
/** A type hint after an identifier can resolve that identifier's inferred type. */
function typeHintOffset(view: EditorView, at: number): number | null {
if (at <= 0 || at > view.state.doc.length) return null;
const word = view.state.wordAt(at - 1);
return word?.to === at ? word.from : null;
}
/** Right-click on a hint: turn its kind off (IDEA's "Disable '…' hints"), or open the settings. */
export function inlayMenuItems(kind: InlayHint["kind"]): MenuItem[] {
return [
{ label: `Disable ${KIND_LABEL[kind]}`, run: () => void setEditorSetting(`inlay.${kind}`, false) },
{ label: tx`Disable ${KIND_LABEL[kind]}`, run: () => void setEditorSetting(`inlay.${kind}`, false) },
{ kind: "separator" },
{ label: "Inlay Hints Settings…", run: () => void executeAction("file.settings", "editor.inlayHints") },
];
@@ -81,6 +90,22 @@ class InlayWidget extends WidgetType {
el.appendChild(span);
}
} else el.textContent = this.hint.label;
// A plain type hint (basedpyright's `: Series`) names no location: ⌘-click opens the preceding identifier's type.
if (
this.hint.kind === "type" &&
!parts.some((p) => p.location) &&
typeHintOffset(view, posToOffset(view.state.doc, this.hint.position)) !== null
) {
el.classList.add("cm-lsp-inlay-part");
el.addEventListener("mousedown", (e) => {
if (e.button !== 0 || !isNavigationGesture(e)) return;
const offset = typeHintOffset(view, view.posAtDOM(el));
if (offset === null) return;
e.preventDefault();
e.stopPropagation();
void gotoTargets(view, "typeDefinition", offset);
});
}
if (this.hint.tooltip) el.title = this.hint.tooltip;
el.setAttribute("aria-hidden", "true");
el.addEventListener("contextmenu", (e) => {
@@ -131,8 +156,11 @@ const inlayPlugin = ViewPlugin.fromClass(
private refresh = debounce(() => void this.run(), 400);
private unsubscribe: () => void;
private offReady: () => void;
private offModifier: () => void;
constructor(private view: EditorView) {
this.refresh();
view.contentDOM.classList.toggle("cm-lsp-navigation-modifier", navigationModifierHeld());
this.offModifier = onNavigationModifier((held) => view.contentDOM.classList.toggle("cm-lsp-navigation-modifier", held));
this.unsubscribe = useProject.subscribe((s, prev) => {
if (s.settings?.editor?.inlayHints !== prev.settings?.editor?.inlayHints || s.settings?.editor?.inlay !== prev.settings?.editor?.inlay)
this.refresh();
@@ -167,6 +195,8 @@ const inlayPlugin = ViewPlugin.fromClass(
this.refresh.cancel();
this.unsubscribe();
this.offReady();
this.offModifier();
this.view.contentDOM.classList.remove("cm-lsp-navigation-modifier");
}
},
);
+2 -1
Переглянути файл
@@ -21,6 +21,7 @@ import { spellingIntentions } from "./spelling";
import { inspectionsAt } from "./diagnostics";
import { SPELLING_INSPECTION } from "@/platform/inspections";
import { editInspectionSettings, intentionControls } from "@/platform/inspectionControls";
import { tx } from "@/platform/i18n";
const isQuickFix = (a: CodeAction) => !!a.kind && (a.kind === "quickfix" || a.kind.startsWith("quickfix."));
@@ -195,7 +196,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[])
...items,
...inspections.map((inspection) => ({
id: `inspection:${inspection.id}`,
label: `Inspection '${inspection.name}' options`,
label: tx`Inspection '${inspection.name}' options`,
icon: "settings",
run: () => editInspectionSettings(inspection.id),
submenu: intentionControls(inspection),
+60 -13
Переглянути файл
@@ -52,6 +52,7 @@ import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs";
import { shortcutLabel } from "@/platform/keymap";
import { isNavigationGesture, onNavigationModifier } from "@/platform/navigationModifier";
import { create } from "zustand";
import { tx } from "@/platform/i18n";
export async function navigateTo(loc: Location) {
await editorService().openFile(loc.path, {
@@ -224,7 +225,7 @@ async function gotoWebReference(view: EditorView, doc: EditorDocContext, pos: Po
openPopup({
kind: "list",
anchor: caretAnchor(view, offset),
title: `Choose Declaration of '${label}' (${targets.length} found)`,
title: tx`Choose Declaration of '${label}' (${targets.length} found)`,
items: await locationItems(targets),
pin: pinToFind(label, targets),
});
@@ -320,15 +321,14 @@ async function probeNavigation(view: EditorView, offset: number): Promise<{ info
try {
navigable = (await lspRequest(() => commands.lspGoto(doc.doc, pos, "definition"))).length > 0;
} catch (e) {
if (isCancelled(e)) return null;
if (isUnavailable(e)) navigable = !!(await gotoFallback(view, doc, pos))?.length;
if (!isUnavailable(e)) throw e;
navigable = !!(await gotoFallback(view, doc, pos))?.length;
}
if (!navigable && WEB_LANGUAGES.has(doc.language) && !doc.virtual)
navigable = !!(await lspRequest(() => commands.webGoto(doc.doc, pos)).catch(() => null))?.targets.length;
}
if (!navigable) return null;
const hover = await requestHover(view, offset).catch(() => null);
return { info: hover ? declarationSummary(hover.contents) : null };
return { info: null };
}
const linkPlugin = ViewPlugin.fromClass(
@@ -339,6 +339,10 @@ const linkPlugin = ViewPlugin.fromClass(
private probedDoc: Text | null = null;
private probing: string | null = null;
private off: () => void;
private disposed = false;
private hovered: string | null = null;
private probeVersion = 0;
private probeTimer: ReturnType<typeof setTimeout> | undefined;
constructor(readonly view: EditorView) {
// Window-wide, so ⌘/Ctrl over an unfocused editor underlines too; released anywhere clears it.
this.off = onNavigationModifier((held) => {
@@ -347,10 +351,16 @@ const linkPlugin = ViewPlugin.fromClass(
}
destroy() {
this.off();
this.disposed = true;
this.probeVersion++;
clearTimeout(this.probeTimer);
}
private set(value: LinkState | null) {
const current = this.view.state.field(linkField);
if (current === value || (current && value && current.from === value.from && current.to === value.to && current.info === value.info))
if (
current === value ||
(current && value && current.from === value.from && current.to === value.to && current.info === value.info)
)
return;
if (!current && !value) return;
this.view.dispatch({ effects: setLink.of(value) });
@@ -361,10 +371,19 @@ const linkPlugin = ViewPlugin.fromClass(
// Underline whole links once the server's links are in.
if (doc && !doc.virtual && !knownLinks(view))
void documentLinks(view, doc).then(() => {
if (view.state.field(linkField)) this.show(true);
if (!this.disposed && view.state.field(linkField)) this.show(true);
});
const target = meta && this.last ? linkAt(view, this.last.x, this.last.y) : null;
const hovered = target ? `${target.from}:${target.to}` : null;
if (hovered !== this.hovered) {
this.hovered = hovered;
this.probeVersion++;
clearTimeout(this.probeTimer);
this.probing = null;
}
if (!target) {
this.probeVersion++;
clearTimeout(this.probeTimer);
this.probing = null;
this.set(null);
return;
@@ -388,13 +407,41 @@ const linkPlugin = ViewPlugin.fromClass(
if (current && (current.from !== target.from || current.to !== target.to)) this.set(null);
if (this.probing === key) return;
this.probing = key;
const version = ++this.probeVersion;
clearTimeout(this.probeTimer);
// A timed-out probe must be retryable, rather than cached as "no target".
this.probeTimer = setTimeout(() => {
if (this.probeVersion === version) {
this.probeVersion++;
this.probing = null;
}
}, 3000);
const docAt = view.state.doc;
void probeNavigation(view, target.from).then((result) => {
if (view.state.doc !== docAt) return;
this.probed.set(key, result);
if (this.probing !== key) return;
this.probing = null;
if (result) this.set({ from: target.from, to: target.to, info: result.info });
void probeNavigation(view, target.from)
.then((result) => {
if (this.disposed || view.state.doc !== docAt || this.probeVersion !== version) return;
clearTimeout(this.probeTimer);
this.probed.set(key, result);
if (this.probing !== key) return;
this.probing = null;
if (result) {
this.set({ from: target.from, to: target.to, info: null });
// Documentation must never delay the link cursor or underline.
void requestHover(view, target.from)
.then((hover) => {
if (this.disposed || view.state.doc !== docAt || this.probeVersion !== version || !hover) return;
const info = declarationSummary(hover.contents);
this.probed.set(key, { info });
this.set({ from: target.from, to: target.to, info });
})
.catch(() => {});
}
})
.catch(() => {
if (this.disposed || this.probeVersion !== version) return;
clearTimeout(this.probeTimer);
this.probing = null;
// Cancellation and server errors are transient, not negative results.
});
}
},
+6 -5
Переглянути файл
@@ -29,6 +29,7 @@ import {
renameRisks,
type LocalRenamePlan,
} from "./syntaxFallback";
import { tx } from "@/platform/i18n";
async function applyEdit(view: EditorView, edit: WorkspaceEdit) {
try {
@@ -83,7 +84,7 @@ export async function startRename(view: EditorView) {
openPopup({
kind: "rename",
anchor: caretAnchor(view, from),
title: `Rename '${oldName}'`,
title: tx`Rename '${oldName}'`,
initial,
onSubmit: (newName) => void finishRename(view, doc, pos, oldName, newName),
});
@@ -99,7 +100,7 @@ export async function startRename(view: EditorView) {
startInplaceRename(view, {
primary: { from, to },
others,
hint: keys ? `Press ${keys} to show dialog with more options` : "Rename in place: ↵ to finish, Esc to cancel",
hint: keys ? tx`Press ${keys} to show dialog with more options` : "Rename in place: ↵ to finish, Esc to cancel",
commit: (newName) => void commitInplace(view, doc, pos, oldName, newName),
dialog,
});
@@ -223,7 +224,7 @@ async function finishRename(view: EditorView, doc: EditorDocContext, pos: Pos, o
}
openPopup({
kind: "preview",
title: `Rename '${oldName}' to '${newName}'`,
title: tx`Rename '${oldName}' to '${newName}'`,
summary,
confirmLabel: "Do Refactor",
onConfirm: () => void applyEdit(view, edit),
@@ -265,7 +266,7 @@ async function startSyntaxRename(view: EditorView, doc: EditorDocContext, pos: P
openPopup({
kind: "rename",
anchor: caretAnchor(view, rangeToOffsets(view.state.doc, (at ?? plan.occurrences[0]!).range).from),
title: `Rename '${plan.symbol.name}'`,
title: tx`Rename '${plan.symbol.name}'`,
initial: plan.symbol.name,
note: `${SYNTAX_NOTE}: local occurrences only`,
onSubmit: (newName) => void commitSyntaxRename(view, doc, plan, newName),
@@ -315,7 +316,7 @@ export async function commitSyntaxRename(view: EditorView, doc: EditorDocContext
});
openPopup({
kind: "preview",
title: `Rename '${oldName}' to '${newName}'`,
title: tx`Rename '${oldName}' to '${newName}'`,
summary: {
files: 1,
occurrences: occurrences.length,
+3 -2
Переглянути файл
@@ -80,7 +80,7 @@ export interface SemanticRange {
}
/** Decodes absolute `[line, col, len, type, modifiers]` quintuples into styled ranges. */
export function semanticRanges(doc: Text, tokens: SemanticTokens, rainbow = false): SemanticRange[] {
export function semanticRanges(doc: Text, tokens: SemanticTokens, rainbow = false, language?: string): SemanticRange[] {
const out: SemanticRange[] = [];
const { data, tokenTypes, tokenModifiers } = tokens;
// Servers with a `local` modifier (TypeScript) tell module-level variables apart.
@@ -102,6 +102,7 @@ export function semanticRanges(doc: Text, tokens: SemanticTokens, rainbow = fals
if (constant && (type === "variable" || type === "property")) base = "constant";
if (base && base !== "variable") classes.push(`cm-sem-${base}`);
for (const n of names) if (MODIFIER_CLASS[n]) classes.push(`cm-sem-${MODIFIER_CLASS[n]}`);
if (language === "python" && names.includes("static")) classes.push("cm-sem-python-static");
const local =
rainbow &&
!constant &&
@@ -163,7 +164,7 @@ const semanticPlugin = ViewPlugin.fromClass(
}
decorate(doc: Text, tokens: SemanticTokens | null) {
this.last = { doc, tokens };
const ranges = tokens ? semanticRanges(doc, tokens, this.rainbow) : [];
const ranges = tokens ? semanticRanges(doc, tokens, this.rainbow, docOf(this.view)?.language) : [];
this.view.dispatch({
effects: setSemantic.of(Decoration.set(ranges.map((r) => markFor(r.className).range(r.from, r.to)), true)),
});
+5 -4
Переглянути файл
@@ -27,6 +27,7 @@ import { rangeToOffsets } from "./positions";
import { showHint } from "./hint";
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
import { renameNamePart } from "./rename";
import { tx } from "@/platform/i18n";
const CHECK_DELAY = 800;
@@ -124,7 +125,7 @@ async function changeTo(view: EditorView, typo: PlacedTypo) {
openPopup({
kind: "list",
anchor: caretAnchor(view, typo.from),
title: `${rename ? "Rename" : "Change"} '${typo.word}' to`,
title: rename ? tx`Rename '${typo.word}' to` : tx`Change '${typo.word}' to`,
items: suggestions.map((s, i) => ({ id: `${i}`, label: s, run: () => applyCorrection(view, typo, s) })),
});
}
@@ -142,7 +143,7 @@ export function spellingIntentions(view: EditorView): ListPopupItem[] {
? [
{
id: "spelling.saveProject",
label: `Save '${typo.word}' to project-level dictionary`,
label: tx`Save '${typo.word}' to project-level dictionary`,
icon: "bulb-fix",
run: () => saveToDictionary(typo.word, "project"),
},
@@ -150,7 +151,7 @@ export function spellingIntentions(view: EditorView): ListPopupItem[] {
: []),
{
id: "spelling.saveIde",
label: `Save '${typo.word}' to application-level dictionary`,
label: tx`Save '${typo.word}' to application-level dictionary`,
icon: "bulb-fix",
run: () => saveToDictionary(typo.word, "ide"),
},
@@ -210,7 +211,7 @@ export function spellingExtension(): Extension {
typos.map((t) => ({
range: t.range,
severity: "info",
message: `Typo: In word '${t.word}'`,
message: tx`Typo: In word '${t.word}'`,
source: "Spelling",
code: null,
})),
+85 -1
Переглянути файл
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState, Text } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { IdeError, Locals, NodeAt, Range, SymbolMatch, SyntaxNode } from "@/bindings";
@@ -8,6 +8,9 @@ const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
const unavailable = () => Promise.resolve({ status: "error" as const, error: { kind: "unavailable", message: "no server" } as IdeError });
const cmd = {
lspHover: vi.fn((): R<unknown> => ok(null)),
lspDocumentLinks: vi.fn(() => ok([])),
cancelRequest: vi.fn(() => ok(null)),
lspGoto: vi.fn((): R<unknown> => unavailable()),
lspDocumentHighlights: vi.fn((): R<unknown> => unavailable()),
lspPrepareRename: vi.fn((): R<unknown> => unavailable()),
@@ -390,3 +393,84 @@ describe("rename wiring", () => {
expect(useLspPopup.getState().popup?.kind).toBe("rename");
});
});
describe("modifier-hover navigation recovery", () => {
const target = { path: "/p/target.ts", range: r(0, 0, 1), uri: null };
let mac = false;
beforeAll(async () => {
mac = (await import("@/platform/keymap")).isMacHost();
});
function move(view: EditorView, pos = 0, primary = true) {
vi.spyOn(view, "posAtCoords").mockReturnValue(pos);
view.contentDOM.dispatchEvent(
new MouseEvent("mousemove", {
bubbles: true,
metaKey: primary && mac,
ctrlKey: primary && !mac,
}),
);
}
it("shows the link without waiting for documentation", async () => {
cmd.lspGoto.mockReturnValueOnce(ok([target]));
cmd.lspHover.mockReturnValueOnce(new Promise(() => {}));
const view = editor("foo");
move(view);
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("foo"));
expect(cmd.lspHover).toHaveBeenCalled();
});
it("retries a transient server error instead of caching no target", async () => {
cmd.lspGoto.mockReturnValueOnce(
Promise.resolve({ status: "error", error: { kind: "internal", message: "timeout" } }),
);
const view = editor("foo");
move(view);
await new Promise((resolve) => setTimeout(resolve, 20));
cmd.lspGoto.mockReturnValueOnce(ok([target]));
move(view);
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("foo"));
expect(cmd.lspGoto).toHaveBeenCalledTimes(2);
});
it("retries a stuck probe and ignores its eventual response", async () => {
let finish!: (value: Awaited<R<unknown>>) => void;
cmd.lspGoto.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
const view = editor("foo");
vi.useFakeTimers();
try {
move(view);
await vi.advanceTimersByTimeAsync(3001);
cmd.lspGoto.mockReturnValueOnce(ok([target]));
move(view);
expect(cmd.lspGoto).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("foo"));
finish({ status: "ok", data: [] });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("foo");
});
it("ignores a late probe after moving to a different word", async () => {
let finish!: (value: Awaited<R<unknown>>) => void;
cmd.lspGoto.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
cmd.lspGoto.mockReturnValueOnce(ok([target]));
const view = editor("foo bar");
move(view);
move(view, 4);
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("bar"));
finish({ status: "ok", data: [target] });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("bar");
});
});
+2 -1
Переглянути файл
@@ -9,6 +9,7 @@ import type { Location, Pos, Range, SymbolKind, SymbolMatch } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { structure, type Locals, type LocalSymbol, type NodeAt } from "@/editor/structure";
import { comparePos, rangeContains, rangeToOffsets } from "./positions";
import { tx } from "@/platform/i18n";
/** Subtle marker for answers that did not come from the language server. */
export const SYNTAX_NOTE = "Based on syntax (language server not ready)";
@@ -237,7 +238,7 @@ export async function planLocalRename(
if (locals.status === "notLocal")
return {
kind: "refused",
message: `Can't rename '${locals.name}' without the language server: only local variables can be renamed based on syntax`,
message: tx`Can't rename '${locals.name}' without the language server: only local variables can be renamed based on syntax`,
};
const { symbol } = locals;
const ranges = [symbol.definition, ...symbol.references].sort((a, b) => comparePos(a.start, b.start));
+7 -2
Переглянути файл
@@ -328,7 +328,11 @@ export const lspTheme = EditorView.theme({
userSelect: "none",
},
// A part naming a declaration (⌘-click navigates there).
".cm-lsp-inlay-part:hover": { textDecoration: "underline", textUnderlineOffset: "2px" },
".cm-lsp-navigation-modifier .cm-lsp-inlay-part:hover": {
textDecoration: "underline",
textUnderlineOffset: "2px",
cursor: "pointer",
},
// Inline completion: the suggestion as gray text at the caret.
".cm-inline-ghost": { color: "var(--fg-subtle)", whiteSpace: "pre" },
".cm-lsp-inlay-pl": { marginLeft: "4px" },
@@ -348,7 +352,8 @@ export const lspTheme = EditorView.theme({
".cm-sem-self": { color: "var(--syn-self, var(--syn-keyword))" },
".cm-sem-keyword": { color: "var(--syn-keyword)", fontWeight: "var(--syn-keyword-bold, inherit)" },
".cm-sem-builtin:not(.cm-sem-function)": { color: "var(--syn-builtin, inherit)" },
".cm-sem-static": { fontStyle: "italic" },
".cm-sem-static": { fontStyle: "var(--syn-static-italic, italic)" },
".cm-sem-python-static": { fontStyle: "var(--syn-python-static-italic, normal)" },
".cm-sem-mutable": { textDecoration: "underline", textDecorationColor: "var(--fg-faint)", textUnderlineOffset: "3px" },
".cm-sem-deprecated": { textDecoration: "line-through" },
// Semantic highlighting: after the base classes so a parameter's colour gives way, and over the
+2 -1
Переглянути файл
@@ -20,6 +20,7 @@ import { editorService } from "@/workbench/contributions";
import { fileIconName } from "@/ui";
import { gotoTestOrSubject, type TestNavDeps } from "./gotoTest";
import { baseName, memberAt, methodStop, superMethods } from "./structureNav";
import { tx } from "@/platform/i18n";
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
@@ -97,7 +98,7 @@ async function superMethod(view: EditorView) {
openPopup({
kind: "list",
anchor: caretAnchor(view, head),
title: method ? `Choose Super Method of ${baseName(method.name)} (${locs.length} found)` : `Choose Super Class or Interface (${locs.length} found)`,
title: method ? tx`Choose Super Method of ${baseName(method.name)} (${locs.length} found)` : tx`Choose Super Class or Interface (${locs.length} found)`,
items: await locationItems(locs),
});
}
+3 -2
Переглянути файл
@@ -35,6 +35,7 @@ import {
type DeclResult,
type Declaration,
} from "./templates";
import { tx } from "@/platform/i18n";
/** Why the language server could not do it (see `withFallback`). */
export type FallbackReason = "notReady" | "error" | "empty";
@@ -594,7 +595,7 @@ async function chooseOccurrences(p: Prepared, self: Occurrence, others: Occurren
},
{
id: "all",
label: `Replace all ${others.length + 1} occurrences`,
label: tx`Replace all ${others.length + 1} occurrences`,
onActive: () => highlightRanges(view, [self, ...others]),
run: () => apply(true),
},
@@ -663,7 +664,7 @@ function applyAndName(p: Prepared, plan: Plan, names: string[]) {
initial: name,
suggestions: names,
note: NOTES[p.reason],
hint: `↵ Apply · Esc Keep '${name}'`,
hint: tx`↵ Apply · Esc Keep '${name}'`,
onSubmit: (chosen) => void rename(p, chosen, name, { original, applied, plan, ranges }),
});
}
+6 -3
Переглянути файл
@@ -16,7 +16,10 @@ let outline: Answer<OutlineItem[]> = ok([]);
let references: Answer<Location[]> = ok([]);
const lspGoto = vi.fn(async () => goto());
const docOutline = vi.fn(async () => outline());
const lspReferences = vi.fn(async () => references());
const lspReferences = vi.fn(async () => {
const r = references();
return r.status === "ok" ? { status: "ok" as const, data: { locations: r.data, incomplete: null } } : r;
});
const lspCodeActions = vi.fn(async () => ({ status: "ok", data: [] }));
const lspApplyCodeAction = vi.fn(async () => ({ status: "ok", data: null }));
const docOpen = vi.fn(async (path: string) => ({ status: "ok", data: { id: 99, path } }));
@@ -261,7 +264,7 @@ describe("safeDelete in the editor", () => {
expect((await run("delete")).state.doc.toString()).toBe("fn unused() {\n unused();\n}\n\nfn main() {}\n");
});
it("confirms first ('Delete function …?') and searches nothing when cancelled", async () => {
it("confirms first ('Delete Function …?') and searches nothing when cancelled", async () => {
const v = editor(text, "unused() {");
outline = ok([item(text, "unused", "fn unused() {\n unused();\n}")]);
confirmAnswer = null;
@@ -269,7 +272,7 @@ describe("safeDelete in the editor", () => {
expect(confirmChecked).toHaveBeenCalledTimes(1);
const opts = confirmChecked.mock.calls[0]![0];
expect(opts.title).toBe("Safe Delete");
expect(opts.message).toBe("Delete function 'unused'?");
expect(opts.message).toBe("Delete Function 'unused'?");
expect(opts.checkbox).toBe("Search in comments and strings");
expect(lspReferences).not.toHaveBeenCalled();
expect(v.state.doc.toString()).toBe(text);
+21 -11
Переглянути файл
@@ -13,7 +13,7 @@
import type { EditorView } from "@codemirror/view";
import type { Text } from "@codemirror/state";
import type { LocalSymbol, Location, OutlineItem, Pos, Range, SyntaxNode } from "@/bindings";
import { call, commands, isCancelled, isTauri } from "@/platform/ipc";
import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { basename } from "@/platform/project";
import { editorsFor, whenSynced, type EditorDocContext } from "@/editor/api";
@@ -25,6 +25,7 @@ import { ask, confirmChecked } from "@/workbench/messages";
import { openPopup, type PreviewOccurrence } from "@/editor/lsp/popupStore";
import { cannotPerform } from "./catalog";
import { cancelled, languageName } from "./query";
import { tx } from "@/platform/i18n";
// ---------------------------------------------------------------------------
// Deletion range (pure)
@@ -252,7 +253,7 @@ async function findUsages(ctx: EditorDocContext, target: SafeDeleteTarget): Prom
comparePos(range.start, target.range.end) > 0;
if (target.local)
return target.local.references.filter((r) => outside(ctx.path, r)).map((range) => ({ path: ctx.path, range }));
const refs = await lspRequest(() => commands.lspReferences(ctx.doc, target.at, false));
const { locations: refs } = await lspRequest(() => commands.lspReferences(nextRequestId(), ctx.doc, target.at, false, true));
return refs.filter((l) => outside(l.path, l.range));
}
@@ -269,11 +270,19 @@ export function usagesMessage(
): { message: string; detail: string } {
const n = usages.length;
const files = new Set(usages.map((u) => u.path)).size;
const noun = n === 1 ? "usage that is" : "usages that are";
return {
message: `${n} ${n === 1 ? "usage" : "usages"} found`,
detail: `${subject} ${plural ? "have" : "has"} ${n} ${noun} not safe to delete${files > 1 ? ` (in ${files} files)` : ""}.`,
};
const where = files > 1 ? tx` (in ${files} files)` : "";
let detail: string;
if (plural)
detail =
n === 1
? tx`${subject} have ${n} usage that is not safe to delete${where}.`
: tx`${subject} have ${n} usages that are not safe to delete${where}.`;
else
detail =
n === 1
? tx`${subject} has ${n} usage that is not safe to delete${where}.`
: tx`${subject} has ${n} usages that are not safe to delete${where}.`;
return { message: n === 1 ? tx`${n} usage found` : tx`${n} usages found`, detail };
}
/** IDEA's "Usages Detected" question; resolves "cancel" when dismissed. */
@@ -391,7 +400,7 @@ function previewTextOccurrences(
});
openPopup({
kind: "preview",
title: `Safe Delete ${target.kind.toLowerCase()} '${target.name}'`,
title: tx`Safe Delete ${target.kind} '${target.name}'`,
summary: {
files: 1,
occurrences: occurrences.length,
@@ -436,7 +445,7 @@ export async function safeDelete(view: EditorView): Promise<void> {
// IDEA always confirms first; usages are searched after OK.
const confirmed = await confirmChecked({
title: "Safe Delete",
message: `Delete ${target.kind.toLowerCase()} '${target.name}'?`,
message: tx`Delete ${target.kind} '${target.name}'?`,
ok: "OK",
checkbox: "Search in comments and strings",
checked: searchInComments,
@@ -453,7 +462,7 @@ export async function safeDelete(view: EditorView): Promise<void> {
]);
if (view.state.doc !== docBefore) return;
if (usages.length) {
const answer = await askUsagesDetected(`${target.kind} '${target.name}'`, usages);
const answer = await askUsagesDetected(tx`${target.kind} '${target.name}'`, usages);
if (answer === "view") viewUsages(target.name, usages);
else if (answer === "delete") applyDeletion(view, docBefore, target, comments);
else view.focus();
@@ -517,7 +526,8 @@ export async function fileUsages(paths: readonly string[], signal?: AbortSignal)
if (signal?.aborted) return;
let refs: Location[];
try {
refs = await lspRequest(() => commands.lspReferences(doc, item.selection_range.start, false));
// Every symbol of every file: the server's own scope (a project search each would be too slow).
refs = (await lspRequest(() => commands.lspReferences(nextRequestId(), doc, item.selection_range.start, false, false))).locations;
} catch (e) {
if (isUnavailable(e)) {
reason = errorText(e);
+4 -3
Переглянути файл
@@ -27,6 +27,7 @@ import type { RefactoringId } from "./catalog";
import { expressionCandidates, expressionPreview } from "./introduce";
import { findTextOccurrences } from "./occurrences";
import { isIdentifier, isKeyword, suggestNames, uniqueNames } from "./names";
import { tx } from "@/platform/i18n";
/** Refactorings that take an expression (chosen first when nothing is selected). */
export const EXPRESSION_REFACTORINGS: ReadonlySet<RefactoringId> = new Set([
@@ -135,7 +136,7 @@ export function chooseOccurrences(view: EditorView, actions: readonly CodeAction
},
{
id: "all",
label: n > 1 ? `Replace all ${n} occurrences` : "Replace all occurrences",
label: n > 1 ? tx`Replace all ${n} occurrences` : "Replace all occurrences",
onActive: () => highlightRanges(view, n ? occurrences : [{ from: sel.from, to: sel.to }]),
run: () => applyIntroduceAction(view, variants.all, id),
},
@@ -291,13 +292,13 @@ export async function applyIntroduceAction(view: EditorView, action: CodeAction,
primary,
others,
suggestions: names.length > 1 ? names : undefined,
hint: keys ? `Press ${keys} to show dialog with more options` : "↵ to finish, Esc to keep the name",
hint: keys ? tx`Press ${keys} to show dialog with more options` : "↵ to finish, Esc to keep the name",
commit: (name) => renameIntroduced(view, [primary, ...others], found.name, name),
dialog: (typed) =>
openPopup({
kind: "rename",
anchor: caretAnchor(view, primary.from),
title: `Rename '${found.name}'`,
title: tx`Rename '${found.name}'`,
initial: typed,
suggestions: names,
onSubmit: (name) => renameIntroduced(view, [primary, ...others], found.name, name),
+99
Переглянути файл
@@ -0,0 +1,99 @@
.view {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.view > * {
flex: 1;
min-height: 0;
}
.row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.groupLabel {
color: var(--fg-strong);
font-weight: 600;
}
.count {
color: var(--fg-subtle);
font-size: var(--fs-tiny);
}
.title {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg-subtle);
}
.age {
flex: none;
padding-right: 8px;
color: var(--fg-subtle);
font-size: var(--fs-small);
font-variant-numeric: tabular-nums;
}
.ready {
color: var(--success);
}
.failed {
color: var(--danger);
}
.archived {
color: var(--fg-subtle);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 24px 12px;
color: var(--fg-muted);
}
.link {
border: 0;
background: none;
padding: 0;
color: var(--link);
font: inherit;
cursor: pointer;
}
.link:hover {
color: var(--link-hover);
}
.statusItem {
display: inline-flex;
align-items: center;
gap: 10px;
height: 100%;
padding: 0 6px;
border: 0;
border-radius: var(--radius-small);
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.statusItem:hover {
background: var(--bg-hover);
}
.statusPart {
display: inline-flex;
align-items: center;
gap: 5px;
}
+29
Переглянути файл
@@ -0,0 +1,29 @@
/** Status bar widget: tasks waiting for the user / running; opens the most urgent one. */
import { Icon, Spinner, T } from "@/ui";
import { openTask, useAgents } from "./store";
import styles from "./Agents.module.css";
export function AgentsStatus() {
const tasks = useAgents((s) => s.tasks);
const list = [...tasks.values()];
const input = list.filter((t) => t.status === "needsInput");
const running = list.filter((t) => t.status === "running" || t.status === "starting").length;
if (!input.length && !running) return null;
const target = input[0] ?? list.find((t) => t.status === "running");
return (
<button type="button" className={styles.statusItem} onClick={() => target && openTask(target.id)}>
{input.length > 0 && (
<span className={styles.statusPart}>
<Icon name="warning" size={13} />
{input.length} <T>needs input</T>
</span>
)}
{running > 0 && (
<span className={styles.statusPart}>
<Spinner size={10} />
{running} <T>running</T>
</span>
)}
</button>
);
}
+129
Переглянути файл
@@ -0,0 +1,129 @@
/** Agents tool window: tasks grouped by what they need from the user. */
import { useMemo, useState } from "react";
import type { AgentStatus as Status, AgentTask } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { confirmChecked } from "@/workbench/messages";
import { Icon, IconButton, Spinner, T, Tree, showContextMenu, type MenuItem } from "@/ui";
import { age, branchLabel, groupTasks, isBusy, type TaskGroup } from "./model";
import { archiveTask, deleteTask, openHome, openTask, useAgents } from "./store";
import styles from "./Agents.module.css";
import { tx } from "@/platform/i18n";
export function AgentsHeaderActions() {
return <IconButton icon="plus" action="agents.newTask" size="small" />;
}
type Node = { kind: "group"; group: TaskGroup } | { kind: "task"; task: AgentTask };
const nodeKey = (n: Node) => (n.kind === "group" ? `g:${n.group.status}` : n.task.id);
export function TaskStatusIcon({ status }: { status: Status }) {
switch (status) {
case "needsInput":
return <Icon name="warning" size={13} label="Needs input" />;
case "running":
case "starting":
return <Spinner size={11} />;
case "ready":
return <Icon name="check" size={13} className={styles.ready} label="Ready" />;
case "failed":
return <Icon name="close" size={13} className={styles.failed} label="Failed" />;
case "archived":
return <Icon name="minus" size={13} className={styles.archived} label="Archived" />;
}
}
export function AgentsView() {
const loaded = useAgents((s) => s.loaded);
const tasks = useAgents((s) => s.tasks);
const providers = useAgents((s) => s.providers);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["g:archived"]));
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const roots = useMemo<Node[]>(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]);
const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]);
if (!loaded) return <Spinner />;
const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
return (
<div className={styles.view}>
<Tree<Node>
aria-label="Agent tasks"
roots={roots}
getKey={nodeKey}
getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)}
getChildren={(n) => (n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined)}
isExpandable={(n) => n.kind === "group"}
expanded={expanded}
onExpandedChange={(_next, node, open) =>
setCollapsed((c) => {
const s = new Set(c);
if (open) s.delete(nodeKey(node));
else s.add(nodeKey(node));
return s;
})
}
selected={selected}
onSelectionChange={(sel, lead) => {
setSelected(sel);
if (lead?.kind === "task") openTask(lead.task.id);
}}
onActivate={(n) => n.kind === "task" && openTask(n.task.id)}
onContextMenu={(n, _sel, e) => n?.kind === "task" && showContextMenu(e, taskMenu(n.task))}
renderNode={(n) =>
n.kind === "group" ? (
<span className={styles.row}>
<span className={styles.groupLabel}>
<T>{n.group.label}</T>
</span>
<span className={styles.count}>{n.group.tasks.length}</span>
</span>
) : (
<span className={styles.row} title={n.task.error ?? n.task.title}>
<TaskStatusIcon status={n.task.status} />
<span className={styles.title}>{n.task.title}</span>
<span className={styles.detail}>
{providerName(n.task.provider)}
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
</span>
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
</span>
)
}
empty={
<div className={styles.empty}>
<T>No agent tasks yet</T>
<button type="button" className={styles.link} onClick={() => openHome()}>
<T>New Task…</T>
</button>
</div>
}
/>
</div>
);
}
function taskMenu(task: AgentTask): MenuItem[] {
const busy = isBusy(task.status);
return [
{ label: "Open", run: () => openTask(task.id) },
{ label: "Open in Terminal", icon: "terminal", run: () => void executeAction("terminal.openAt", task.cwd) },
{ kind: "separator" },
{ label: "Archive", disabled: busy || task.status === "archived", run: () => void archiveTask(task.id) },
{ label: "Delete…", run: () => void confirmDelete(task) },
];
}
async function confirmDelete(task: AgentTask) {
const worktree = task.isolation === "worktree";
const r = await confirmChecked({
title: "Delete Agent Task",
message: worktree
? tx`Delete “${task.title}”? Its branch ${task.branch ?? ""} is kept.`
: tx`Delete “${task.title}”? Files it changed are kept.`,
ok: "Delete",
icon: "warning",
checkbox: worktree ? "Also remove its worktree folder" : undefined,
checked: worktree,
});
if (r) await deleteTask(task.id, worktree && r.checked);
}
+145
Переглянути файл
@@ -0,0 +1,145 @@
/* A framed text area (IDEA text field frame), with the pickers in its bottom row. */
.composer {
position: relative;
display: flex;
flex-direction: column;
border-radius: var(--radius-popup);
background: var(--bg-field, var(--bg-button));
box-shadow: inset 0 0 0 1px var(--border-button);
cursor: text;
transition: box-shadow var(--dur-fast) var(--ease);
}
.composer:focus-within {
box-shadow:
inset 0 0 0 1px var(--focus-ring),
0 0 0 1px var(--focus-ring);
}
.textarea {
display: block;
width: 100%;
min-height: 44px;
resize: none;
border: 0;
outline: none;
background: transparent;
color: var(--fg-strong);
font: inherit;
font-size: var(--fs-ui);
line-height: 1.5;
padding: 10px 12px 2px;
-webkit-user-select: text;
user-select: text;
}
.textarea:focus-visible {
outline: none;
}
.textarea::placeholder {
color: var(--fg-faint);
}
.bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 4px 6px 6px;
cursor: default;
}
.spacer {
flex: 1;
}
.pickerValue {
display: inline-flex;
align-items: baseline;
gap: 5px;
white-space: nowrap;
}
.pickerName {
color: var(--fg-subtle);
}
.images {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 10px 0;
cursor: default;
}
.image {
position: relative;
width: 44px;
height: 44px;
border-radius: var(--radius-small);
box-shadow: inset 0 0 0 1px var(--border);
overflow: hidden;
}
.image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.removeImage {
position: absolute;
top: 2px;
right: 2px;
display: grid;
place-items: center;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--bg-popup);
color: var(--fg);
cursor: pointer;
opacity: 0;
}
.image:hover .removeImage,
.removeImage:focus-visible {
opacity: 1;
}
/* `@` / `/` completion, above the box like a code-completion popup. */
.popup {
position: absolute;
left: 0;
bottom: calc(100% + 4px);
z-index: 10;
width: min(560px, 100%);
max-height: 300px;
overflow: auto;
padding: 4px 0;
border: 1px solid var(--border-popup);
border-radius: var(--radius-popup);
background: var(--bg-popup);
box-shadow: var(--shadow-popup);
cursor: default;
}
.item {
display: flex;
align-items: center;
gap: 6px;
height: var(--h-row);
margin: 0 4px;
padding: 0 8px;
border-radius: var(--radius-row);
white-space: nowrap;
}
.active {
background: var(--bg-list-selected);
}
.itemName {
flex: none;
color: var(--fg-strong);
}
.itemHint {
flex: none;
color: var(--fg-muted);
font-style: italic;
}
.itemDetail {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-subtle);
}
+370
Переглянути файл
@@ -0,0 +1,370 @@
/**
* The message box of the New Task page and of sessions: `@` file references (project file
* search), `/` agent commands, ↑ / ↓ history, pasted or dropped images, Shift+Tab to cycle
* the agent's mode. Its draft lives in the store under `id`.
*/
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import clsx from "clsx";
import type { AgentCommand, AgentConfigOption, AgentImage } from "@/bindings";
import { call, commands, isTauri } from "@/platform/ipc";
import { useTx } from "@/platform/i18n";
import { basename } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { Icon, IconButton, fileIconName } from "@/ui";
import {
IMAGE_EXTENSIONS,
MAX_IMAGE_BYTES,
applyCompletion,
atEdgeLine,
completionAt,
imageFromDataUrl,
matchCommands,
mentionOf,
modeOption,
nextValue,
recall,
type Completion,
type FileRef,
} from "./composerModel";
import { composerDraft, updateComposer, useAgents } from "./store";
import styles from "./Composer.module.css";
import { isImeKey } from "@/platform/ime";
const MAX_PROMPT_HEIGHT = 280;
const MAX_ITEMS = 12;
type Item =
| { kind: "file"; path: string; rel: string }
| { kind: "command"; command: AgentCommand };
export interface ComposerProps {
/** Store key of the draft. */
id: string;
placeholder: string;
/** Directory `@` references resolve in (the task's worktree, or the project root). */
root: string;
/** The project root the file index covers. */
projectRoot: string;
commands: readonly AgentCommand[];
/** Sent messages, newest first. */
history: readonly string[];
/** Settings; Shift+Tab cycles the mode one. */
config: readonly AgentConfigOption[];
onConfig?: (option: string, value: string) => void;
/** `now`: ⌘↵ (send even while the agent works, interrupting it). */
onSubmit: (now: boolean) => void;
disabled?: boolean;
autoFocus?: boolean;
/** Left of the bottom row: agent / model / mode pickers. */
toolbar?: ReactNode;
/** Right of the bottom row: send / stop buttons. */
actions?: ReactNode;
className?: string;
}
export function Composer(props: ComposerProps) {
const { id, root, projectRoot } = props;
const tx = useTx();
const draft = useAgents((s) => s.composers.get(id));
const text = draft?.text ?? "";
const images = draft?.images ?? [];
const ref = useRef<HTMLTextAreaElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const imageInput = useRef<HTMLInputElement>(null);
const [completion, setCompletion] = useState<Completion | null>(null);
const [files, setFiles] = useState<Item[]>([]);
const [active, setActive] = useState(0);
const recalled = useRef<{ index: number; stash: string }>({ index: -1, stash: "" });
useEffect(() => {
if (props.autoFocus) ref.current?.focus();
}, [props.autoFocus]);
// Grow with the text, up to a limit.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, MAX_PROMPT_HEIGHT)}px`;
}, [text]);
// `@` completion: project file search, mapped into the task's directory.
useEffect(() => {
if (completion?.kind !== "file") return;
let alive = true;
const timer = setTimeout(() => {
call(commands.findFiles(completion.query, MAX_ITEMS))
.then((found) => {
if (!alive) return;
setFiles(
found.map((f) => ({
kind: "file",
rel: f.relPath,
path: root === projectRoot ? f.path : `${root}/${f.relPath}`,
})),
);
})
.catch(() => alive && setFiles([]));
}, 60);
return () => {
alive = false;
clearTimeout(timer);
};
}, [completion?.kind, completion?.query, root, projectRoot]);
const items: Item[] = useMemo(() => {
if (!completion) return [];
if (completion.kind === "file") return files;
return matchCommands(props.commands, completion.query)
.slice(0, MAX_ITEMS)
.map((command) => ({ kind: "command", command }));
}, [completion, files, props.commands]);
const open = !!completion && items.length > 0;
const setText = (value: string, caret?: number) => {
updateComposer(id, { text: value });
const c = caret ?? value.length;
setCompletion(completionAt(value, c));
setActive(0);
if (caret !== undefined) {
requestAnimationFrame(() => {
ref.current?.focus();
ref.current?.setSelectionRange(caret, caret);
});
}
};
const accept = (item: Item | undefined) => {
if (!item || !completion) return;
if (item.kind === "file") {
const mention = mentionOf(item.rel);
const next = applyCompletion(text, completion, mention);
const current = composerDraft(id);
updateComposer(id, { files: [...current.files, { path: item.path, rel: item.rel } satisfies FileRef] });
setText(next.text, next.caret);
} else {
const next = applyCompletion(text, completion, `/${item.command.name}`);
setText(next.text, next.caret);
}
setCompletion(null);
};
const addImages = (list: AgentImage[]) => {
if (list.length) updateComposer(id, { images: [...composerDraft(id).images, ...list] });
};
const readImageFiles = (picked: Iterable<File>) => {
for (const file of picked) {
if (!file.type.startsWith("image/")) continue;
if (file.size > MAX_IMAGE_BYTES) {
notifications().notify({ level: "warning", title: "Image too large", body: tx`${file.name} is over 5 MB.` });
continue;
}
const reader = new FileReader();
reader.onload = () => {
const image = typeof reader.result === "string" ? imageFromDataUrl(reader.result, file.name || "image") : null;
if (image) addImages([image]);
};
reader.readAsDataURL(file);
}
};
// Files dropped from the OS onto the box: images are attached, other files referenced.
useEffect(() => {
if (!isTauri()) return;
let unlisten: (() => void) | undefined;
let disposed = false;
void import("@tauri-apps/api/webview").then(({ getCurrentWebview }) =>
getCurrentWebview()
.onDragDropEvent((event) => {
const p = event.payload;
if (p.type !== "drop" || !p.paths.length) return;
const box = boxRef.current?.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
const [x, y] = [p.position.x / ratio, p.position.y / ratio];
if (!box || x < box.left || x > box.right || y < box.top || y > box.bottom) return;
const refs: FileRef[] = [];
for (const path of p.paths) {
if (IMAGE_EXTENSIONS.test(path)) {
void call(commands.fsReadImage(path)).then((url) => {
const image = imageFromDataUrl(url, basename(path));
if (image) addImages([image]);
});
} else {
refs.push({ path, rel: path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path });
}
}
if (refs.length) {
const current = composerDraft(id);
const mentions = refs.map((r) => mentionOf(r.rel)).join(" ");
const glue = current.text && !/\s$/.test(current.text) ? " " : "";
updateComposer(id, { text: `${current.text}${glue}${mentions} `, files: [...current.files, ...refs] });
}
})
.then((u) => {
if (disposed) u();
else unlisten = u;
}),
);
return () => {
disposed = true;
unlisten?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-subscribe only when the target changes
}, [id, root]);
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (isImeKey(e.nativeEvent)) return;
if (open) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const d = e.key === "ArrowDown" ? 1 : -1;
setActive((a) => (a + d + items.length) % items.length);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
accept(items[active]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setCompletion(null);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!props.disabled) props.onSubmit(e.metaKey || e.ctrlKey);
recalled.current = { index: -1, stash: "" };
return;
}
if (e.key === "Tab" && e.shiftKey) {
const mode = modeOption(props.config);
const value = mode && nextValue(mode);
if (mode && value && props.onConfig) {
e.preventDefault();
props.onConfig(mode.id, value);
}
return;
}
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !e.altKey && !e.metaKey) {
const dir = e.key === "ArrowUp" ? -1 : 1;
const el = e.currentTarget;
const r = recalled.current;
// Only from an empty box or one showing a recalled message, with the caret on the edge line.
const fresh = r.index === -1 ? text === "" : text === props.history[r.index];
if (!fresh || !atEdgeLine(text, el.selectionStart, dir)) return;
const next = recall(props.history, r.index, dir);
if (next === null) return;
e.preventDefault();
if (r.index === -1) r.stash = text;
r.index = next;
const value = next === -1 ? r.stash : props.history[next]!;
setText(value, value.length);
setCompletion(null);
}
};
return (
<div ref={boxRef} className={clsx(styles.composer, props.className)} onClick={() => ref.current?.focus()}>
{open && (
<div className={styles.popup} role="listbox" aria-label={tx(completion.kind === "file" ? "Files" : "Commands")}>
{items.map((item, i) => (
<div
key={item.kind === "file" ? item.path : item.command.name}
role="option"
aria-selected={i === active}
className={clsx(styles.item, i === active && styles.active)}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => {
e.preventDefault();
accept(item);
}}
>
{item.kind === "file" ? (
<>
<Icon name={fileIconName(basename(item.rel))} size={16} />
<span className={styles.itemName}>{basename(item.rel)}</span>
<span className={styles.itemDetail}>{item.rel}</span>
</>
) : (
<>
<span className={styles.itemName}>/{item.command.name}</span>
{item.command.hint && <span className={styles.itemHint}>{item.command.hint}</span>}
<span className={styles.itemDetail}>{item.command.description}</span>
</>
)}
</div>
))}
</div>
)}
{images.length > 0 && (
<div className={styles.images} onClick={(e) => e.stopPropagation()}>
{images.map((img, i) => (
<span key={i} className={styles.image} title={img.name ?? undefined}>
<img src={`data:${img.mimeType};base64,${img.data}`} alt={img.name ?? tx("Image")} />
<button
type="button"
className={styles.removeImage}
aria-label={tx("Remove Image")}
onClick={() => updateComposer(id, { images: images.filter((_, j) => j !== i) })}
>
<Icon name="close" size={10} />
</button>
</span>
))}
</div>
)}
<textarea
ref={ref}
className={styles.textarea}
rows={2}
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
value={text}
aria-label={tx("Message")}
placeholder={tx(props.placeholder)}
onChange={(e) => setText(e.target.value, undefined)}
onSelect={(e) => setCompletion(completionAt(e.currentTarget.value, e.currentTarget.selectionStart))}
onBlur={() => setCompletion(null)}
onKeyDown={onKeyDown}
onPaste={(e) => {
const pasted = [...e.clipboardData.files].filter((f) => f.type.startsWith("image/"));
if (pasted.length) {
e.preventDefault();
readImageFiles(pasted);
}
}}
/>
<div className={styles.bar} onClick={(e) => e.stopPropagation()}>
{props.toolbar}
<span className={styles.spacer} />
<IconButton
icon="file"
title="Reference a File (@)"
size="small"
onClick={() => {
const glue = text && !/\s$/.test(text) ? " " : "";
setText(`${text}${glue}@`, text.length + glue.length + 1);
}}
/>
<IconButton icon="file-image" title="Attach Image…" size="small" onClick={() => imageInput.current?.click()} />
<input
ref={imageInput}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
hidden
onChange={(e) => {
readImageFiles(e.target.files ?? []);
e.target.value = "";
}}
/>
{props.actions}
</div>
</div>
);
}
+57
Переглянути файл
@@ -0,0 +1,57 @@
/**
* Model / mode / thinking-effort dropdowns for a composer's bottom row. Like other agent
* UIs, only the mode gets icons (one per mode, by what it allows); levels are plain text.
*/
import type { AgentConfigOption } from "@/bindings";
import { useTx } from "@/platform/i18n";
import { Select } from "@/ui";
import { mainOptions, modeIcon } from "./composerModel";
import styles from "./Composer.module.css";
export function ConfigPickers({
config,
values,
onChange,
}: {
config: readonly AgentConfigOption[];
/** Chosen values that override `current`, by option id. */
values?: Record<string, string>;
onChange: (option: string, value: string) => void;
}) {
const tx = useTx();
return (
<>
{mainOptions(config).map((o) => {
const isMode = o.category === "mode";
const value = values?.[o.id] ?? o.current ?? null;
return (
<Select
key={o.id}
variant="ghost"
aria-label={o.name}
value={value}
placeholder={tx(o.name)}
options={o.values.map((v) => ({
value: v.value,
label: v.name,
icon: isMode ? modeIcon(v.value) : undefined,
detail: v.description ?? undefined,
}))}
onChange={(v) => onChange(o.id, v)}
// Effort and other levels say what they are: "Effort Medium".
renderValue={
o.category === "thought_level"
? (current) => (
<span className={styles.pickerValue}>
<span className={styles.pickerName}>{o.name}</span>
{current?.label ?? tx("Default")}
</span>
)
: undefined
}
/>
);
})}
</>
);
}
+53
Переглянути файл
@@ -0,0 +1,53 @@
.home {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 24px 12vh;
background: var(--bg-editor);
color: var(--fg);
}
.inner {
width: min(720px, 100%);
display: flex;
flex-direction: column;
gap: 8px;
}
.heading {
margin: 0 0 10px;
font-size: 18px;
font-weight: 600;
text-align: center;
color: var(--fg-strong);
text-wrap: balance;
}
/* Under the box: the project and where the agent works. */
.where {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 0 2px;
font-size: var(--fs-small);
}
.project {
margin-right: 6px;
color: var(--fg-muted);
}
.note,
.warning {
margin: 0;
padding: 0 4px;
font-size: var(--fs-small);
color: var(--fg-subtle);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.warning {
color: var(--danger);
}
+137
Переглянути файл
@@ -0,0 +1,137 @@
/** New Task page: one prompt box with the agent and its settings; where it works under it. */
import { useEffect, useState } from "react";
import type { Branch } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { Button, Select, T, type SelectOption } from "@/ui";
import { Composer } from "./Composer";
import { canSend, emptyDraft } from "./composerModel";
import { ConfigPickers } from "./ConfigPickers";
import { providerIcon } from "./model";
import { HOME, startTask, updateDraft, useAgents } from "./store";
import styles from "./Home.module.css";
export function HomeEditor() {
const tx = useTx();
const txTemplate = useTxTemplate();
const draft = useAgents((s) => s.draft);
const providers = useAgents((s) => s.providers);
const history = useAgents((s) => s.history);
const composer = useAgents((s) => s.composers.get(HOME)) ?? emptyDraft();
const project = useProject((s) => s.project);
const [branches, setBranches] = useState<Branch[]>([]);
const [current, setCurrent] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
useEffect(() => {
call(commands.vcsBranches(null))
.then((list) => {
const local = list.filter((b) => !b.remote);
setBranches(local);
setCurrent(local.find((b) => b.current)?.name ?? null);
})
.catch(() => setBranches([]));
}, []);
const provider = providers.find((p) => p.id === draft.provider) ?? providers[0];
// Installed agents first; the rest under a "Not installed" title, with the command they need.
const installed = providers.filter((p) => p.installed);
const missing = providers.filter((p) => !p.installed);
const providerOptions: SelectOption[] = [
...installed.map((p) => ({ value: p.id, label: p.name, icon: providerIcon(p.id) })),
...(missing.length ? [{ value: "__missing__", label: tx("Not installed"), disabled: true }] : []),
...missing.map((p) => ({ value: p.id, label: p.name, icon: providerIcon(p.id), detail: p.command })),
];
const base = draft.base ?? current;
const canStart = canSend(composer) && !!provider?.installed && !starting;
const root = project?.root ?? "";
const start = () => {
if (!canStart) return;
setStarting(true);
setError(null);
startTask()
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setStarting(false));
};
const setOption = (id: string, value: string) => updateDraft({ config: { ...draft.config, [id]: value } });
return (
<div className={styles.home}>
<div className={styles.inner}>
<h1 className={styles.heading}>
<T>What should the agent do?</T>
</h1>
<Composer
id={HOME}
autoFocus
placeholder="Describe a task for the agent. @ references a file, / runs a command"
root={root}
projectRoot={root}
commands={provider?.commands ?? []}
history={history}
config={provider?.config ?? []}
onConfig={setOption}
onSubmit={start}
disabled={!canStart}
toolbar={
<>
<Select
variant="ghost"
aria-label="Agent"
value={provider?.id ?? null}
options={providerOptions}
onChange={(id) => updateDraft({ provider: id, config: {} })}
/>
<ConfigPickers config={provider?.config ?? []} values={draft.config} onChange={setOption} />
</>
}
actions={
<Button variant="primary" size="small" disabled={!canStart} onClick={start}>
{starting ? "Starting…" : "Start"}
</Button>
}
/>
<div className={styles.where}>
<span className={styles.project}>{project?.name}</span>
<Select
variant="ghost"
aria-label="Isolation"
value={draft.isolation}
options={[
{ value: "worktree", label: tx("New worktree"), icon: "branch", detail: tx("away from your changes") },
{ value: "local", label: tx("Local checkout"), icon: "folder", detail: tx("edits your files directly") },
]}
onChange={(isolation) => updateDraft({ isolation })}
/>
{draft.isolation === "worktree" && branches.length > 0 && (
<>
<span className={styles.note}>
<T>from</T>
</span>
<Select
variant="ghost"
aria-label="Base branch"
value={base}
options={branches.map((b) => ({ value: b.name, label: b.name }))}
onChange={(b) => updateDraft({ base: b === current ? null : b })}
/>
</>
)}
</div>
{(error || (provider && !provider.installed) || (provider && !provider.config.length)) && (
<p className={error || !provider?.installed ? styles.warning : styles.note} role={error ? "alert" : undefined}>
{error ??
(provider && !provider.installed ? (
txTemplate(`${provider.command} was not found on PATH`)
) : (
<T>Models and modes appear after this agent's first task.</T>
))}
</p>
)}
</div>
</div>
);
}
+29
Переглянути файл
@@ -0,0 +1,29 @@
/** Agent messages: Markdown → sanitized HTML (marked + DOMPurify). */
import { useMemo } from "react";
import { Marked } from "marked";
import DOMPurify from "dompurify";
import { openExternalUrl } from "@/platform/openUrl";
import styles from "./Session.module.css";
const marked = new Marked({ gfm: true, breaks: true });
export function markdownHtml(text: string): string {
const html = marked.parse(text, { async: false }) as string;
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
}
export function Markdown({ text }: { text: string }) {
const html = useMemo(() => markdownHtml(text), [text]);
return (
<div
className={styles.markdown}
onClick={(e) => {
const a = (e.target as HTMLElement).closest("a");
if (!a) return;
e.preventDefault();
if (/^https?:/i.test(a.href)) void openExternalUrl(a.href);
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
+410
Переглянути файл
@@ -0,0 +1,410 @@
.session {
flex: 1;
min-height: 0;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
background: var(--bg-editor);
color: var(--fg);
}
.conversation {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border-right: 1px solid var(--border);
}
.header {
display: flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 6px 0 12px;
border-bottom: 1px solid var(--border);
flex: none;
min-width: 0;
}
.title {
flex: 0 1 auto;
min-width: 0;
font-weight: 600;
color: var(--fg-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.headerDetail {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
color: var(--fg-subtle);
}
.headerText {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.providerIcon {
color: var(--fg-muted);
}
.muted {
color: var(--fg-subtle);
}
/* Pending permission: an editor notification banner. */
.banner {
flex: none;
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 12px;
background: var(--bg-feedback-warning);
border-bottom: 1px solid var(--border-feedback-warning);
color: var(--fg-strong);
}
.bannerText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bannerSpacer {
flex: 1;
}
.bannerLink {
flex: none;
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
white-space: nowrap;
cursor: pointer;
}
.bannerLink:hover:not(:disabled) {
color: var(--link-hover);
text-decoration: underline;
}
.bannerLink:disabled {
color: var(--fg-subtle);
cursor: default;
}
.stream {
flex: 1;
min-height: 0;
overflow: auto;
padding: 14px 16px 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.user {
padding: 2px 0 2px 10px;
border-left: 2px solid var(--accent);
color: var(--fg-strong);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.markdown {
line-height: 1.55;
overflow-wrap: anywhere;
}
.markdown :global(p) {
margin: 0 0 8px;
}
.markdown :global(p:last-child) {
margin-bottom: 0;
}
.markdown :global(code) {
font-family: var(--font-mono);
font-size: var(--fs-small);
padding: 0 4px;
border-radius: 3px;
background: var(--bg-elev);
}
.markdown :global(pre) {
padding: 8px 10px;
border-radius: var(--radius-small);
background: var(--bg-elev);
overflow: auto;
}
.markdown :global(pre code) {
padding: 0;
background: none;
}
.markdown :global(ul),
.markdown :global(ol) {
margin: 0 0 8px;
padding-left: 20px;
}
.markdown :global(a) {
color: var(--link);
}
.thought summary,
.toolDetails > summary {
cursor: pointer;
list-style: none;
}
.thought summary::-webkit-details-marker,
.toolDetails > summary::-webkit-details-marker {
display: none;
}
.thought summary {
color: var(--fg-muted);
font-size: var(--fs-small);
}
.thoughtText {
margin-top: 4px;
padding-left: 10px;
border-left: 2px solid var(--border-strong);
color: var(--fg-secondary);
white-space: pre-wrap;
}
.tool {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 2px 8px;
margin-left: -8px;
border-radius: var(--radius-row);
color: var(--fg-secondary);
width: fit-content;
max-width: 100%;
}
.toolDetails > summary.tool:hover {
background: var(--bg-hover);
color: var(--fg);
}
.toolTitle {
font-family: var(--font-mono);
font-size: var(--fs-small);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toolDiff {
font: inherit;
font-size: var(--fs-small);
border: 0;
background: none;
padding: 0 4px;
border-radius: var(--radius-small);
cursor: pointer;
white-space: nowrap;
}
.toolDiff:hover {
background: var(--bg-hover);
}
.output {
margin: 4px 0 0;
max-height: 240px;
overflow: auto;
padding: 8px 10px;
border-radius: var(--radius-small);
border: 1px solid var(--border);
background: var(--bg-elev);
font-family: var(--font-mono);
font-size: var(--fs-small);
white-space: pre-wrap;
color: var(--fg-secondary);
}
.plan {
display: grid;
gap: 2px;
}
.planTitle {
font-weight: 600;
color: var(--fg-strong);
}
.planItem {
display: flex;
align-items: center;
gap: 6px;
}
.planDone {
color: var(--fg-muted);
text-decoration: line-through;
}
.permissionLine {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: var(--fg-secondary);
}
.permissionTitle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error {
display: flex;
gap: 6px;
align-items: flex-start;
color: var(--danger);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.working {
display: flex;
align-items: center;
gap: 8px;
color: var(--fg-muted);
}
.composerArea {
flex: none;
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px 12px 12px;
}
.queue {
display: flex;
flex-direction: column;
gap: 2px;
}
.queued {
display: flex;
align-items: center;
gap: 6px;
min-height: 24px;
padding: 0 4px 0 8px;
border-radius: var(--radius-row);
background: var(--bg-elev);
}
.queuedText {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage {
flex: none;
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--fg-subtle);
font-size: var(--fs-small);
font-variant-numeric: tabular-nums;
}
.usageBar {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--border);
overflow: hidden;
}
.usageBar > span {
display: block;
height: 100%;
background: var(--fg-muted);
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
margin-top: 4px;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.attachment {
display: inline-flex;
align-items: center;
gap: 4px;
}
.changes {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.changesHeader {
display: flex;
align-items: center;
gap: 6px;
height: 32px;
flex: none;
padding: 0 6px 0 10px;
border-bottom: 1px solid var(--border);
}
.changesTitle {
font-weight: 600;
color: var(--fg-strong);
}
.changesSummary {
flex: none;
padding: 4px 10px;
color: var(--fg-subtle);
white-space: pre;
}
.changesList {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.changesList > * {
flex: 1;
min-height: 0;
}
.changesEmpty {
padding: 8px 10px;
color: var(--fg-muted);
}
.change {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
}
.changeName {
flex: none;
white-space: nowrap;
}
.changeDir {
flex: 1 1 0;
min-width: 0;
padding-left: 4px;
color: var(--fg-subtle);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.changeStat {
flex: none;
padding-right: 8px;
font-size: var(--fs-small);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.added {
color: var(--vcs-added);
}
.removed {
color: var(--danger);
}
+453
Переглянути файл
@@ -0,0 +1,453 @@
/** An agent task as an editor tab: the conversation, a composer and the task's changed files. */
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, commands } from "@/platform/ipc";
import { basename, relativeDir, useProject } from "@/platform/project";
import { editorService, notifications, type EditorInput } from "@/workbench/contributions";
import { Button, EmptyState, Icon, IconButton, Spinner, T, Tree, fileIconName } from "@/ui";
import { TaskStatusIcon } from "./AgentsView";
import { Composer } from "./Composer";
import { canSend, emptyDraft, usageLabel } from "./composerModel";
import { ConfigPickers } from "./ConfigPickers";
import { Markdown } from "./Markdown";
import { branchLabel, isBusy, providerIcon, statusLabel } from "./model";
import {
archiveTask,
cancelTask,
loadEntries,
markVisible,
refreshChanges,
editQueued,
removeQueued,
respond,
sendMessage,
sendQueuedNow,
setTaskConfig,
useAgents,
} from "./store";
import styles from "./Session.module.css";
export function SessionEditor({ input }: { input: EditorInput }) {
const id = (input.data as { task: string }).task;
const task = useAgents((s) => s.tasks.get(id));
const entries = useAgents((s) => s.entries.get(id));
const changes = useAgents((s) => s.changes.get(id));
useEffect(() => {
void loadEntries(id).catch(() => undefined);
void refreshChanges(id);
return markVisible(id);
}, [id]);
if (!task) return <EmptyState title="This agent task no longer exists" />;
return (
<div className={styles.session}>
<div className={styles.conversation}>
<Header task={task} />
<PermissionBanner task={task} entries={entries ?? null} />
<Stream task={task} entries={entries ?? null} />
<SessionComposer task={task} entries={entries ?? null} />
</div>
<ChangesPane task={task} changes={changes ?? null} />
</div>
);
}
function providerName(id: string) {
return useAgents.getState().providers.find((p) => p.id === id)?.name ?? id;
}
function Header({ task }: { task: AgentTask }) {
const busy = isBusy(task.status);
const where = task.isolation === "worktree" ? branchLabel(task.branch) : null;
return (
<div className={styles.header}>
<TaskStatusIcon status={task.status} />
<span className={styles.title} title={task.title}>
{task.title}
</span>
<span className={styles.headerDetail} title={task.cwd}>
<Icon name={providerIcon(task.provider)} size={13} className={styles.providerIcon} />
<span className={styles.headerText}>
{providerName(task.provider)}
{task.model ? ` · ${task.model}` : ""}
{" · "}
{where ?? <T>Local checkout</T>}
{" · "}
<T>{statusLabel(task.status)}</T>
</span>
</span>
{task.usage && <Usage task={task} />}
{busy && <IconButton icon="stop" title="Stop" size="small" onClick={() => void cancelTask(task.id)} />}
<IconButton
icon="terminal"
title="Open in Terminal"
size="small"
onClick={() => void executeAction("terminal.openAt", task.cwd)}
/>
{!busy && task.status !== "archived" && (
<IconButton icon="close" title="Archive Task" size="small" onClick={() => void archiveTask(task.id)} />
)}
</div>
);
}
/** Context window use: "12%", the numbers (and cost) in the tooltip. */
function Usage({ task }: { task: AgentTask }) {
const label = usageLabel(task.usage!);
return (
<span className={styles.usage} title={label.detail} aria-label={`Context ${label.short}, ${label.detail}`}>
<span className={styles.usageBar} aria-hidden>
<span style={{ width: label.short }} />
</span>
{label.short}
</span>
);
}
function Stream({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
const ref = useRef<HTMLDivElement>(null);
const stick = useRef(true);
useLayoutEffect(() => {
const el = ref.current;
if (el && stick.current) el.scrollTop = el.scrollHeight;
});
const waiting = entries?.some((e) => e.type === "permission" && !e.chosen);
return (
<div
ref={ref}
className={styles.stream}
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}}
>
{entries === null ? (
<Spinner />
) : (
entries.map((entry, i) => <Entry key={i} task={task} entry={entry} />)
)}
{(task.status === "running" || task.status === "starting") && !waiting && (
<div className={styles.working}>
<Spinner size={12} />
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
</div>
)}
</div>
);
}
const TOOL_ICONS: Record<string, string> = {
read: "file",
edit: "edit",
delete: "minus",
move: "edit",
search: "search",
execute: "terminal",
fetch: "web",
think: "todo",
};
function Entry({ task, entry }: { task: AgentTask; entry: AgentEntry }) {
switch (entry.type) {
case "user":
return (
<div className={styles.user}>
{entry.text}
{entry.attachments.length > 0 && (
<div className={styles.attachments}>
{entry.attachments.map((a, i) => (
<span key={i} className={styles.attachment}>
<Icon name={fileIconName(a)} size={12} />
{a}
</span>
))}
</div>
)}
</div>
);
case "agent":
return <Markdown text={entry.text} />;
case "thought":
return (
<details className={styles.thought}>
<summary>
<T>Thinking</T>
</summary>
<div className={styles.thoughtText}>{entry.text}</div>
</details>
);
case "tool":
return <ToolEntry task={task} entry={entry} />;
case "plan":
return (
<div className={styles.plan}>
<div className={styles.planTitle}>
<T>Plan</T>
</div>
{entry.items.map((item, i) => (
<div key={i} className={clsx(styles.planItem, item.status === "completed" && styles.planDone)}>
<Icon name={item.status === "completed" ? "check" : item.status === "in_progress" ? "run" : "minus"} size={12} />
{item.content}
</div>
))}
</div>
);
case "permission":
return <PermissionLine task={task} entry={entry} />;
case "error":
return (
<div className={styles.error} role="alert">
<Icon name="error" size={14} />
<span>{entry.message}</span>
</div>
);
}
}
function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry, { type: "tool" }> }) {
const head = (
<>
<Icon name={TOOL_ICONS[entry.kind] ?? "run"} size={14} />
<span className={styles.toolTitle}>{shortTitle(task, entry.title || entry.kind)}</span>
{entry.diffs.map((d) => (
<button
key={d.path}
type="button"
className={styles.toolDiff}
title={d.path}
onClick={(e) => {
e.preventDefault();
void openChangeDiff(task, d.path);
}}
>
<span className={styles.added}>+{d.added}</span> <span className={styles.removed}>−{d.removed}</span>
</button>
))}
{(entry.status === "running" || entry.status === "pending") && <Spinner size={12} />}
{entry.status === "failed" && <Icon name="error" size={12} />}
</>
);
if (!entry.output) return <div className={styles.tool}>{head}</div>;
return (
<details className={styles.toolDetails}>
<summary className={styles.tool}>{head}</summary>
<pre className={styles.output}>{entry.output}</pre>
</details>
);
}
/** Agent-written titles with the task's own folder dropped from paths. */
const shortTitle = (task: AgentTask, title: string) => title.replaceAll(`${task.cwd}/`, "");
function PermissionLine({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry, { type: "permission" }> }) {
return (
<div className={styles.permissionLine}>
<Icon name={!entry.chosen ? "warning" : entry.chosen === "Cancelled" ? "minus" : "check"} size={13} />
<span className={styles.permissionTitle}>{shortTitle(task, entry.title)}</span>
<span className={styles.muted}>{entry.chosen ?? <T>Waiting for your answer</T>}</span>
</div>
);
}
/** The open permission request, as an editor notification banner above the conversation. */
function PermissionBanner({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
const [busy, setBusy] = useState(false);
const entry = entries?.find((e): e is Extract<AgentEntry, { type: "permission" }> => e.type === "permission" && !e.chosen);
if (!entry) return null;
const answer = (option: string | null) => {
setBusy(true);
void respond(task.id, entry.id, option).finally(() => setBusy(false));
};
return (
<div className={styles.banner} role="alert" aria-label={entry.title}>
<Icon name="warning" size={16} />
<span className={styles.bannerText} title={entry.title}>
{shortTitle(task, entry.title)}
</span>
<span className={styles.bannerSpacer} />
{entry.options.map((o) => (
<button key={o.id} type="button" className={styles.bannerLink} disabled={busy} onClick={() => answer(o.id)}>
{o.name}
</button>
))}
</div>
);
}
function SessionComposer({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
const project = useProject((s) => s.project);
const composer = useAgents((s) => s.composers.get(task.id)) ?? emptyDraft();
const [sending, setSending] = useState(false);
const busy = isBusy(task.status);
// ↑ recalls this conversation's messages, newest first.
const history = useMemo(
() => (entries ?? []).flatMap((e) => (e.type === "user" && e.text ? [e.text] : [])).reverse(),
[entries],
);
const send = (now: boolean) => {
if (!canSend(composer) || sending || task.status === "archived") return;
setSending(true);
sendMessage(task.id, now)
.catch((e: unknown) => notifications().notify({ level: "error", title: "Couldn't send the message", body: String(e) }))
.finally(() => setSending(false));
};
const setOption = (id: string, value: string) =>
void setTaskConfig(task.id, id, value).catch((e: unknown) =>
notifications().notify({ level: "error", title: "Couldn't change the setting", body: String(e) }),
);
return (
<div className={styles.composerArea}>
{task.queue.length > 0 && <Queue task={task} />}
<Composer
id={task.id}
placeholder={busy ? "Queue a message for when the agent is done (⌘↵ sends it now)" : "Reply to the agent"}
root={task.cwd}
projectRoot={project?.root ?? task.cwd}
commands={task.commands}
history={history}
config={task.config}
onConfig={setOption}
onSubmit={send}
disabled={task.status === "archived"}
toolbar={<ConfigPickers config={task.config} onChange={setOption} />}
actions={
<>
{busy && (
<Button size="small" icon="stop" onClick={() => void cancelTask(task.id)}>
Stop
</Button>
)}
<Button
size="small"
variant={busy ? "secondary" : "primary"}
disabled={!canSend(composer) || sending || task.status === "archived"}
onClick={() => send(false)}
>
{busy ? "Queue" : "Send"}
</Button>
</>
}
/>
</div>
);
}
/** Messages waiting for the turn to end: send now, edit, or drop. */
function Queue({ task }: { task: AgentTask }) {
const fail = (e: unknown) => notifications().notify({ level: "error", title: "Couldn't update the queue", body: String(e) });
return (
<div className={styles.queue} aria-label="Queued messages">
{task.queue.map((q) => (
<div key={q.id} className={styles.queued}>
<Icon name="pause" size={12} className={styles.muted} />
<span className={styles.queuedText} title={q.prompt.text}>
{q.prompt.text || <T>Image</T>}
</span>
<button type="button" className={styles.bannerLink} onClick={() => void sendQueuedNow(task.id, q.id).catch(fail)}>
<T>Send now</T>
</button>
<IconButton icon="edit" title="Edit" size="small" onClick={() => void editQueued(task.id, q.id).catch(fail)} />
<IconButton icon="close" title="Remove" size="small" onClick={() => void removeQueued(task.id, q.id).catch(fail)} />
</div>
))}
</div>
);
}
const KIND_COLOR: Partial<Record<AgentChange["kind"], string>> = {
added: "var(--vcs-added)",
untracked: "var(--vcs-untracked)",
modified: "var(--vcs-modified)",
renamed: "var(--vcs-modified)",
deleted: "var(--vcs-deleted)",
conflicted: "var(--vcs-conflict)",
};
function ChangesPane({ task, changes }: { task: AgentTask; changes: AgentChange[] | null }) {
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const added = changes?.reduce((n, c) => n + c.added, 0) ?? 0;
const removed = changes?.reduce((n, c) => n + c.removed, 0) ?? 0;
return (
<aside className={styles.changes} aria-label="Changes">
<div className={styles.changesHeader}>
<span className={styles.changesTitle}>
<T>Changes</T>
</span>
{changes && changes.length > 0 && (
<span className={styles.muted}>
{changes.length} <T>{changes.length === 1 ? "file" : "files"}</T>
</span>
)}
<span className={styles.bannerSpacer} />
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshChanges(task.id)} />
</div>
{task.base && (
<div className={styles.changesSummary}>
<T>vs</T> {task.base}
{changes && changes.length > 0 && (
<>
{" "}
<span className={styles.added}>+{added}</span> <span className={styles.removed}>−{removed}</span>
</>
)}
</div>
)}
<div className={styles.changesList}>
{changes === null ? (
<Spinner />
) : (
<Tree<AgentChange>
aria-label="Changed files"
roots={changes}
getKey={(c) => c.path}
getLabel={(c) => basename(c.path)}
getChildren={() => undefined}
isExpandable={() => false}
selected={selected}
onSelectionChange={(sel) => setSelected(sel)}
onActivate={(c) => void openChangeDiff(task, c.path)}
renderNode={(c) => (
<span className={styles.change} title={c.relPath}>
<Icon name={fileIconName(basename(c.path))} size={16} />
<span className={styles.changeName} style={{ color: KIND_COLOR[c.kind] }}>
{basename(c.path)}
</span>
<span className={styles.changeDir}>{relativeDir(task.cwd, c.path)}</span>
<span className={styles.changeStat}>
<span className={styles.added}>+{c.added}</span> <span className={styles.removed}>−{c.removed}</span>
</span>
</span>
)}
empty={
<div className={styles.changesEmpty}>
<T>No changes yet</T>
</div>
}
/>
)}
</div>
</aside>
);
}
/** Opens the diff of `path` between the task's start and now. */
export async function openChangeDiff(task: AgentTask, path: string) {
const base = await call(commands.agentBaseText(task.id, path)).catch(() => null);
const baseLabel = task.base ?? "Base";
const right = task.isolation === "worktree" ? branchLabel(task.branch) : "Working tree";
await editorService().openInput(
{
kind: "diff",
key: `diff:agent:${task.id}:${path}`,
path,
data: {
path,
left: { type: "text", text: base ?? "", label: baseLabel },
right: { type: "file", path, label: right },
title: `${basename(path)} (${baseLabel} ↔ ${right})`,
},
},
{ focus: true },
);
}
+167
Переглянути файл
@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import type { AgentConfigOption, AgentEntry, AgentStatus, AgentTask } from "@/bindings";
import {
applyCompletion,
atEdgeLine,
canSend,
completionAt,
imageFromDataUrl,
mainOptions,
matchCommands,
mentionOf,
modeIcon,
nextValue,
recall,
toPrompt,
usageLabel,
} from "./composerModel";
import { age, attentionChange, branchLabel, groupTasks, upsertEntry } from "./model";
import { markdownHtml } from "./Markdown";
function task(id: string, status: AgentStatus, updatedMs: number): AgentTask {
return {
id,
title: id,
provider: "claude",
model: null,
status,
isolation: "worktree",
cwd: "/w",
branch: `agent/${id}`,
base: "main",
baseCommit: "abc",
error: null,
createdMs: 0,
updatedMs,
config: [],
commands: [],
usage: null,
queue: [],
};
}
const option = (category: string, values: string[], current: string | null): AgentConfigOption => ({
id: category,
name: category,
description: null,
category,
current,
values: values.map((value) => ({ value, name: value, description: null })),
via: "option",
});
describe("agent composer", () => {
it("completes @files anywhere and /commands as the first word", () => {
expect(completionAt("fix @src/le", 11)).toEqual({ kind: "file", query: "src/le", from: 4, to: 11 });
expect(completionAt("/rev", 4)).toEqual({ kind: "command", query: "rev", from: 0, to: 4 });
expect(completionAt("run /rev", 8)).toBeNull();
expect(completionAt("a@b", 3)).toBeNull();
const c = completionAt("see @le and", 7)!;
expect(applyCompletion("see @le and", c, "@src/lexer.rs")).toEqual({ text: "see @src/lexer.rs and", caret: 18 });
const cmds = [
{ name: "init", description: "", hint: null },
{ name: "review", description: "", hint: null },
{ name: "pr-review", description: "", hint: null },
];
expect(matchCommands(cmds, "rev").map((c) => c.name)).toEqual(["review", "pr-review"]);
});
it("sends only the files still mentioned", () => {
const prompt = toPrompt({
text: " look at @src/a.rs:3-5 and @b.rs ",
files: [
{ path: "/w/src/a.rs", rel: "src/a.rs" },
{ path: "/w/b.rs", rel: "b.rs" },
{ path: "/w/b.rs", rel: "b.rs" },
{ path: "/w/gone.rs", rel: "gone.rs" },
],
images: [],
});
expect(prompt).toEqual({ text: "look at @src/a.rs:3-5 and @b.rs", files: ["/w/src/a.rs", "/w/b.rs"], images: [] });
expect(mentionOf("a.rs", { from: 3, to: 3 })).toBe("@a.rs:3");
expect(canSend({ text: " ", files: [], images: [] })).toBe(false);
});
it("recalls history from the edge lines only", () => {
const h = ["newest", "older"];
expect(recall(h, -1, -1)).toBe(0);
expect(recall(h, 0, -1)).toBe(1);
expect(recall(h, 1, -1)).toBeNull();
expect(recall(h, 0, 1)).toBe(-1);
expect(recall(h, -1, 1)).toBeNull();
expect(atEdgeLine("a\nb", 1, -1)).toBe(true);
expect(atEdgeLine("a\nb", 3, -1)).toBe(false);
expect(atEdgeLine("a\nb", 1, 1)).toBe(false);
});
it("picks the main settings and cycles the mode", () => {
const config = [option("fast", ["on"], null), option("mode", ["ask", "code"], "code"), option("model", ["m"], "m")];
expect(mainOptions(config).map((o) => o.id)).toEqual(["model", "mode"]);
expect(nextValue(config[1]!)).toBe("ask");
expect(["default", "acceptEdits", "plan", "auto", "bypassPermissions", "auto_edit", "read-only", "x"].map(modeIcon)).toEqual([
"lock",
"edit",
"todo",
"run",
"unlock",
"edit",
"eye",
"lock",
]);
expect(usageLabel({ used: 24_000, size: 200_000, cost: 0.4, currency: "USD" })).toEqual({
short: "12%",
detail: "24k / 200k tokens · $0.40",
});
expect(imageFromDataUrl("data:image/png;base64,AAA", "a.png")).toEqual({ mimeType: "image/png", data: "AAA", name: "a.png" });
expect(imageFromDataUrl("data:text/plain;base64,AAA")).toBeNull();
});
});
describe("agents model", () => {
it("groups tasks by what they need, newest first", () => {
const groups = groupTasks([
task("a", "ready", 1),
task("b", "needsInput", 2),
task("c", "running", 3),
task("d", "starting", 4),
task("e", "ready", 5),
]);
expect(groups.map((g) => [g.status, g.tasks.map((t) => t.id)])).toEqual([
["needsInput", ["b"]],
["running", ["d", "c"]],
["ready", ["e", "a"]],
]);
});
it("upserts entries in place and refuses gaps", () => {
const a: AgentEntry = { type: "agent", text: "a" };
const b: AgentEntry = { type: "agent", text: "b" };
expect(upsertEntry([a], 0, b)).toEqual([b]);
expect(upsertEntry([a], 1, b)).toEqual([a, b]);
expect(upsertEntry([a], 3, b)).toBeNull();
});
it("notifies on finish, failure and questions only", () => {
expect(attentionChange("running", "ready")).toBe("finished");
expect(attentionChange("running", "failed")).toBe("failed");
expect(attentionChange("running", "needsInput")).toBe("input");
expect(attentionChange("needsInput", "running")).toBeNull();
expect(attentionChange("ready", "archived")).toBeNull();
expect(attentionChange(undefined, "ready")).toBeNull();
});
it("formats labels", () => {
expect(branchLabel("agent/fix-lexer")).toBe("fix-lexer");
expect(branchLabel(null)).toBe("");
expect(age(0, 30_000)).toBe("now");
expect(age(0, 5 * 60_000)).toBe("5m");
expect(age(0, 3 * 3_600_000)).toBe("3h");
});
it("renders agent markdown without scripts", () => {
const html = markdownHtml("**bold** <img src=x onerror=alert(1)><script>alert(1)</script>");
expect(html).toContain("<strong>bold</strong>");
expect(html).not.toContain("onerror");
expect(html).not.toContain("<script");
});
});
+134
Переглянути файл
@@ -0,0 +1,134 @@
/** Pure composer helpers: `@` / `/` completion, attachments, history recall, settings. */
import type { AgentCommand, AgentConfigOption, AgentImage, AgentPrompt, AgentUsage } from "@/bindings";
import { tx } from "@/platform/i18n";
export interface FileRef {
/** Absolute path (in the task's directory). */
path: string;
/** As written after `@`. */
rel: string;
}
export interface ComposerDraft {
text: string;
files: FileRef[];
images: AgentImage[];
}
export const emptyDraft = (): ComposerDraft => ({ text: "", files: [], images: [] });
export type Completion =
| { kind: "file"; query: string; from: number; to: number }
| { kind: "command"; query: string; from: number; to: number };
/** The `@file` or leading `/command` being typed at `caret`, if any. */
export function completionAt(text: string, caret: number): Completion | null {
const before = text.slice(0, caret);
const word = /(^|\s)([@/])([^\s@]*)$/.exec(before);
if (!word) return null;
const from = caret - word[3]!.length - 1;
// Commands only as the message's first word.
if (word[2] === "/") return before.slice(0, from).trim() === "" ? { kind: "command", query: word[3]!, from, to: caret } : null;
return { kind: "file", query: word[3]!, from, to: caret };
}
/** Replaces the completion with `insert` plus a space; returns the text and the caret after it. */
export function applyCompletion(text: string, c: Completion, insert: string): { text: string; caret: number } {
const after = text.slice(c.to);
const glue = after.startsWith(" ") ? "" : " ";
const next = text.slice(0, c.from) + insert + glue + after;
return { text: next, caret: c.from + insert.length + 1 };
}
/** Commands matching `query`, prefix matches first. */
export function matchCommands(commands: readonly AgentCommand[], query: string): AgentCommand[] {
const q = query.toLowerCase();
const hits = commands.filter((c) => c.name.toLowerCase().includes(q));
return hits.sort((a, b) => Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q)));
}
/** `@rel` with a line range suffix when given ("@src/a.rs:10-20"). */
export function mentionOf(rel: string, lines?: { from: number; to: number }): string {
if (!lines) return `@${rel}`;
return lines.from === lines.to ? `@${rel}:${lines.from}` : `@${rel}:${lines.from}-${lines.to}`;
}
/** The prompt to send: files still mentioned in the text (each once), and the images. */
export function toPrompt(draft: ComposerDraft): AgentPrompt {
const seen = new Set<string>();
const files = draft.files.filter((f) => {
if (seen.has(f.path) || !new RegExp(`@${escapeRe(f.rel)}(?![\\w./-])|@${escapeRe(f.rel)}:`).test(draft.text)) return false;
seen.add(f.path);
return true;
});
return { text: draft.text.trim(), files: files.map((f) => f.path), images: draft.images };
}
export const canSend = (d: ComposerDraft) => !!d.text.trim() || d.images.length > 0;
/**
* ↑ / ↓ through sent messages (newest first). `index` −1 is the unsent draft. Returns the
* new index, or `null` when the key should move the caret instead.
*/
export function recall(history: readonly string[], index: number, dir: -1 | 1): number | null {
const next = index + (dir < 0 ? 1 : -1);
if (next < -1 || next >= history.length) return null;
return next;
}
/** Whether ↑ / ↓ recalls history: the caret is on the first / last line. */
export function atEdgeLine(text: string, caret: number, dir: -1 | 1): boolean {
return dir < 0 ? !text.slice(0, caret).includes("\n") : !text.slice(caret).includes("\n");
}
/** The settings shown as dropdowns: model, mode, thinking effort, in that order. */
export function mainOptions(config: readonly AgentConfigOption[]): AgentConfigOption[] {
const order = ["model", "mode", "thought_level"];
return order.flatMap((c) => config.filter((o) => o.category === c).slice(0, 1));
}
export const modeOption = (config: readonly AgentConfigOption[]) => config.find((o) => o.category === "mode");
/**
* An icon per permission mode, by what it lets the agent do (mode ids differ per agent:
* Claude `acceptEdits`, Gemini `auto_edit`, Codex `full-access`…). Unknown modes ask.
*/
export function modeIcon(mode: string): string {
const m = mode.toLowerCase().replace(/[^a-z]/g, "");
if (m.includes("plan") || m === "architect") return "todo";
if (m.includes("bypass") || m === "yolo" || m.includes("fullaccess") || m === "dangerous") return "unlock";
if (m.includes("readonly") || m === "ask" || m === "chat") return "eye";
if (m.includes("edit") || m === "code") return "edit";
if (m === "auto" || m === "agent") return "run";
return "lock";
}
/** Shift+Tab: the mode after the current one. */
export function nextValue(option: AgentConfigOption, current = option.current): string | null {
if (!option.values.length) return null;
const i = option.values.findIndex((v) => v.value === current);
return option.values[(i + 1) % option.values.length]!.value;
}
/** "12%" of the context window and a tooltip with the numbers. */
export function usageLabel(u: AgentUsage): { short: string; detail: string } {
const pct = Math.min(100, Math.round((u.used / u.size) * 100));
const k = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${Math.round(n)}`);
const cost = u.cost != null ? ` · ${u.currency === "USD" || !u.currency ? "$" : `${u.currency} `}${u.cost.toFixed(2)}` : "";
return { short: `${pct}%`, detail: tx`${k(u.used)} / ${k(u.size)} tokens${cost}` };
}
/** `data:image/png;base64,AAA` → an image attachment. */
export function imageFromDataUrl(url: string, name?: string): AgentImage | null {
const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);
if (!m || !m[1]!.startsWith("image/")) return null;
return { mimeType: m[1]!, data: m[2]!, name: name ?? null };
}
export const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp)$/i;
/** Larger images are refused (agents reject them anyway). */
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
function escapeRe(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+113
Переглянути файл
@@ -0,0 +1,113 @@
/**
* Coding agents (Claude Code, Codex, Gemini CLI over ACP, run by `ide-agent`): the
* Agents tool window lists tasks by what they need from the user, a task opens as an
* editor tab (conversation + changed files), and New Task is an editor page.
*/
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { lazyComponent } from "@/ui";
import { isVirtualUri } from "@/platform/virtualDocs";
import {
registerEditorKind,
registerMenuGroup,
registerStatusItem,
registerToolWindow,
toolWindowService,
} from "@/workbench/contributions";
import { AgentsStatus } from "./AgentsStatus";
import { HOME_KIND, SESSION_KIND, TOOL_WINDOW } from "./model";
import { sendPaths, sendSelection } from "./references";
import { connect, disconnect, openHome, useAgents } from "./store";
export function activate(): () => void {
const sync = (root: string | null | undefined) => {
disconnect();
if (root) void connect().catch((e: unknown) => console.warn("agents unavailable", e));
};
sync(useProject.getState().project?.root);
const disposers = [
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) sync(s.project?.root);
}),
registerToolWindow({
id: TOOL_WINDOW,
title: "Agents",
icon: "toolwindow-agents",
anchor: "left",
order: 30,
component: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsView)),
headerActions: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsHeaderActions)),
useBadge: () => {
const n = useAgents((s) => [...s.tasks.values()].filter((t) => t.status === "needsInput").length);
return n || null;
},
}),
registerEditorKind({
kind: SESSION_KIND,
component: lazyComponent(() => import("./SessionEditor").then((m) => m.SessionEditor)),
title: (input) => {
const data = input.data as { task: string; title?: string };
return useAgents.getState().tasks.get(data.task)?.title ?? data.title ?? "Agent Task";
},
icon: () => "toolwindow-agents",
// Tabs of tasks deleted meanwhile are closed when the tasks load (`connect`).
restorable: true,
}),
registerEditorKind({
kind: HOME_KIND,
component: lazyComponent(() => import("./HomeEditor").then((m) => m.HomeEditor)),
title: () => "New Agent Task",
icon: () => "toolwindow-agents",
restorable: true,
}),
registerStatusItem({ id: "agents.status", align: "right", priority: 3, title: "Agents", component: AgentsStatus }),
registerMenuGroup({
id: "agents.send",
menus: ["editor", "projectView"],
order: 80,
items: (menu, { paths }) =>
paths.length && !paths.some(isVirtualUri)
? [{ kind: "action", action: "agents.sendToAgent", arg: menu === "projectView" ? paths : undefined }]
: [],
}),
registerActions([
{
id: "agents.newTask",
title: "New Agent Task",
description: "Opens the New Task page; a string `arg` pre-fills the task description.",
category: "Agents",
icon: "plus",
when: "projectOpen",
keywords: ["agent", "claude", "codex", "gemini", "ai"],
run: ({ arg }) => openHome(typeof arg === "string" ? arg : undefined),
},
{
id: "agents.sendToAgent",
title: "Send to Agent",
description:
"Adds the editor selection (or the file) as an @ reference to the agent message typed last; a string[] `arg` sends those paths.",
category: "Agents",
icon: "toolwindow-agents",
when: "projectOpen",
keywords: ["agent", "chat", "context", "reference", "selection"],
run: ({ arg }) => {
if (Array.isArray(arg)) sendPaths(arg.filter((p): p is string => typeof p === "string"));
else sendSelection();
},
},
{
id: "agents.showToolWindow",
title: "Agents",
category: "Agents",
icon: "toolwindow-agents",
when: "projectOpen",
keywords: ["agent", "tasks"],
run: () => toolWindowService().show(TOOL_WINDOW, true),
},
]),
];
return () => {
disposers.reverse().forEach((d) => d());
disconnect();
};
}
+90
Переглянути файл
@@ -0,0 +1,90 @@
/** Pure helpers: task grouping, entry updates, labels. */
import type { AgentEntry, AgentStatus, AgentTask } from "@/bindings";
export const SESSION_KIND = "agent-session";
export const HOME_KIND = "agent-home";
export const HOME_KEY = "agent:new";
export const TOOL_WINDOW = "agents";
export const sessionKey = (task: string) => `agent:${task}`;
const PROVIDER_ICONS = new Set(["claude", "codex", "gemini", "copilot", "cursor", "opencode", "qwen", "kimi", "goose", "auggie", "kiro", "vibe"]);
/** The agent's logo; custom agents get the generic agents icon. */
export const providerIcon = (provider: string) =>
PROVIDER_ICONS.has(provider) ? `agent-${provider}` : "toolwindow-agents";
export interface TaskGroup {
status: "needsInput" | "failed" | "running" | "ready" | "archived";
label: string;
tasks: AgentTask[];
}
/** Groups in attention order: what waits for the user comes first. */
const GROUPS: Array<[TaskGroup["status"], string, AgentStatus[]]> = [
["needsInput", "Needs Input", ["needsInput"]],
["failed", "Failed", ["failed"]],
["running", "Running", ["running", "starting"]],
["ready", "Ready to Review", ["ready"]],
["archived", "Archived", ["archived"]],
];
export function groupTasks(tasks: Iterable<AgentTask>): TaskGroup[] {
const sorted = [...tasks].sort((a, b) => (b.updatedMs ?? 0) - (a.updatedMs ?? 0));
return GROUPS.map(([status, label, members]) => ({
status,
label,
tasks: sorted.filter((t) => members.includes(t.status)),
})).filter((g) => g.tasks.length > 0);
}
export const isBusy = (s: AgentStatus) => s === "running" || s === "starting" || s === "needsInput";
/** `entries` with `entry` at `index` (appended when `index` is the length; gaps wait for a reload). */
export function upsertEntry(entries: AgentEntry[], index: number, entry: AgentEntry): AgentEntry[] | null {
if (index > entries.length) return null;
const next = entries.slice();
next[index] = entry;
return next;
}
/** "agent/fix-lexer" → "fix-lexer". */
export const branchLabel = (branch: string | null) => (branch ?? "").replace(/^agent\//, "");
export function statusLabel(s: AgentStatus): string {
switch (s) {
case "starting":
return "Starting";
case "running":
return "Running";
case "needsInput":
return "Needs input";
case "ready":
return "Ready";
case "failed":
return "Failed";
case "archived":
return "Archived";
}
}
/** "3m", "2h", "5d" since `ms`. */
export function age(ms: number, now = Date.now()): string {
const s = Math.max(0, Math.round((now - ms) / 1000));
if (s < 60) return "now";
const m = Math.round(s / 60);
if (m < 60) return `${m}m`;
const h = Math.round(m / 60);
if (h < 24) return `${h}h`;
return `${Math.round(h / 24)}d`;
}
/** The status change worth a notification, if any. */
export function attentionChange(prev: AgentStatus | undefined, next: AgentStatus): "finished" | "failed" | "input" | null {
if (prev === next || prev === undefined) return null;
if (next === "needsInput") return "input";
if (!isBusy(prev)) return null;
if (next === "ready") return "finished";
if (next === "failed") return "failed";
return null;
}
+36
Переглянути файл
@@ -0,0 +1,36 @@
/** "Send to Agent": the editor selection or Project view files as `@` references. */
import { activeEditor } from "@/editor/api";
import { useProject } from "@/platform/project";
import { isVirtualUri } from "@/platform/virtualDocs";
import { mentionOf } from "./composerModel";
import { addReferences } from "./store";
const relTo = (root: string, path: string) => (path.startsWith(`${root}/`) ? path.slice(root.length + 1) : null);
/** The active editor's file, with the selected lines when there is a selection. */
export function sendSelection(): boolean {
const handle = activeEditor();
const root = useProject.getState().project?.root;
if (!handle || !root || isVirtualUri(handle.ctx.path)) return false;
const rel = relTo(root, handle.ctx.path);
if (!rel) return false;
const { state } = handle.view;
const sel = state.selection.main;
// A selection ending at a line start does not include that line.
const end = sel.to > sel.from && state.doc.lineAt(sel.to).from === sel.to ? sel.to - 1 : sel.to;
const lines = sel.empty ? undefined : { from: state.doc.lineAt(sel.from).number, to: state.doc.lineAt(end).number };
addReferences([{ path: handle.ctx.path, rel, mention: mentionOf(rel, lines) }]);
return true;
}
/** Files and folders from the Project view. */
export function sendPaths(paths: readonly string[]): boolean {
const root = useProject.getState().project?.root;
if (!root) return false;
const refs = paths.flatMap((path) => {
const rel = relTo(root, path);
return rel ? [{ path, rel, mention: mentionOf(rel) }] : [];
});
if (refs.length) addReferences(refs);
return refs.length > 0;
}
+310
Переглянути файл
@@ -0,0 +1,310 @@
/** Agent tasks of the open project, mirrored from `ide-agent` through `agentSubscribe`. */
import { Channel } from "@tauri-apps/api/core";
import { create } from "zustand";
import type { AgentChange, AgentEntry, AgentEvent, AgentProvider, AgentTask, Isolation } from "@/bindings";
import { call, commands, isTauri } from "@/platform/ipc";
import { editorService, notifications } from "@/workbench/contributions";
import { canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel";
import { HOME_KEY, HOME_KIND, SESSION_KIND, attentionChange, isBusy, sessionKey, upsertEntry } from "./model";
/** New Task page settings (its text lives in the `HOME` composer). */
export interface Draft {
provider: string;
/** Settings chosen for the next task, by option id (reset when the agent changes). */
config: Record<string, string>;
isolation: Isolation;
/** `null`: the current branch. */
base: string | null;
}
/** Composer key of the New Task page; sessions use their task id. */
export const HOME = "home";
interface AgentsState {
loaded: boolean;
tasks: Map<string, AgentTask>;
/** Entries of the tasks opened in this window. */
entries: Map<string, AgentEntry[]>;
changes: Map<string, AgentChange[]>;
providers: AgentProvider[];
draft: Draft;
composers: Map<string, ComposerDraft>;
/** The composer typed in last: where "Send to Agent" adds references. */
lastComposer: string;
/** First messages of past tasks, newest first (New Task page ↑). */
history: string[];
}
const DRAFT_KEY = "openidea.agents.draft.v2";
const HISTORY_KEY = "openidea.agents.history.v1";
const HISTORY_SIZE = 50;
function load<T>(key: string, fallback: T): T {
try {
const saved = JSON.parse(globalThis.localStorage?.getItem(key) ?? "null") as T | null;
return saved ?? fallback;
} catch {
return fallback;
}
}
function save(key: string, value: unknown) {
try {
globalThis.localStorage?.setItem(key, JSON.stringify(value));
} catch {
/* storage unavailable */
}
}
const fallbackDraft: Draft = { provider: "claude", config: {}, isolation: "worktree", base: null };
export const useAgents = create<AgentsState>(() => ({
loaded: false,
tasks: new Map(),
entries: new Map(),
changes: new Map(),
providers: [],
draft: { ...fallbackDraft, ...load<Partial<Draft>>(DRAFT_KEY, {}) },
composers: new Map(),
lastComposer: HOME,
history: load<string[]>(HISTORY_KEY, []),
}));
export function updateDraft(patch: Partial<Draft>) {
const draft = { ...useAgents.getState().draft, ...patch };
useAgents.setState({ draft });
save(DRAFT_KEY, draft);
}
export const composerDraft = (key: string) => useAgents.getState().composers.get(key) ?? emptyDraft();
export function updateComposer(key: string, patch: Partial<ComposerDraft>) {
useAgents.setState((s) => ({
composers: new Map(s.composers).set(key, { ...(s.composers.get(key) ?? emptyDraft()), ...patch }),
lastComposer: key,
}));
}
/** Session tabs on screen; their tasks do not notify. */
const visible = new Set<string>();
export function markVisible(task: string): () => void {
visible.add(task);
return () => visible.delete(task);
}
let generation = 0;
/** Subscribes this window to the project's agents (again after the project changed). */
export async function connect(): Promise<void> {
if (!isTauri()) return;
const gen = ++generation;
const channel = new Channel<AgentEvent>();
channel.onmessage = (e) => {
if (gen === generation) onEvent(e);
};
const [tasks, providers] = await Promise.all([
call(commands.agentSubscribe(channel)),
call(commands.agentProviders()),
]);
if (gen !== generation) return;
useAgents.setState({
loaded: true,
tasks: new Map(tasks.map((t) => [t.id, t])),
entries: new Map(),
changes: new Map(),
providers,
});
// Restored tabs of tasks deleted meanwhile close, like editors of deleted files.
const editors = editorService();
for (const t of editors.openTabs?.() ?? []) {
const task = (t.input.data as { task?: string } | undefined)?.task;
if (t.input.kind === SESSION_KIND && task && !useAgents.getState().tasks.has(task)) editors.closeInput?.(t.input.key);
}
}
export function disconnect() {
generation++;
useAgents.setState({ loaded: false, tasks: new Map(), entries: new Map(), changes: new Map() });
}
function onEvent(e: AgentEvent) {
const s = useAgents.getState();
switch (e.type) {
case "task": {
const prev = s.tasks.get(e.task.id);
useAgents.setState({ tasks: new Map(s.tasks).set(e.task.id, e.task) });
notifyChange(prev, e.task);
if (prev?.status !== e.task.status && !isBusy(e.task.status)) {
void refreshChanges(e.task.id);
void refreshProviders();
}
break;
}
case "removed": {
const tasks = new Map(s.tasks);
tasks.delete(e.id);
const composers = new Map(s.composers);
composers.delete(e.id);
useAgents.setState({ tasks, composers });
editorService().closeInput?.(sessionKey(e.id));
break;
}
case "entry": {
const current = s.entries.get(e.task);
if (!current) return;
const next = upsertEntry(current, e.index, e.entry);
if (next) useAgents.setState({ entries: new Map(s.entries).set(e.task, next) });
else void loadEntries(e.task);
if (e.entry.type === "tool" && e.entry.status === "completed" && e.entry.diffs.length) {
scheduleChanges(e.task);
}
break;
}
}
}
function notifyChange(prev: AgentTask | undefined, task: AgentTask) {
const change = attentionChange(prev?.status, task.status);
if (!change) return;
if (visible.has(task.id) && document.hasFocus()) return;
const body =
change === "input" ? "Waiting for your answer" : change === "failed" ? (task.error ?? "The agent stopped") : "Finished";
notifications().notify({
level: change === "failed" ? "error" : "info",
title: task.title,
body,
key: `agent:${task.id}`,
group: "tools",
sticky: change !== "finished",
actions: [{ label: change === "finished" ? "Review" : "Open", run: () => openTask(task.id) }],
});
}
export async function loadEntries(task: string): Promise<void> {
const entries = await call(commands.agentEntries(task));
useAgents.setState((s) => ({ entries: new Map(s.entries).set(task, entries) }));
}
export async function refreshChanges(task: string): Promise<void> {
try {
const changes = await call(commands.agentChanges(task));
useAgents.setState((s) => ({ changes: new Map(s.changes).set(task, changes) }));
} catch {
// The worktree is gone or not a repository: nothing to list.
useAgents.setState((s) => ({ changes: new Map(s.changes).set(task, []) }));
}
}
const pendingChanges = new Map<string, ReturnType<typeof setTimeout>>();
function scheduleChanges(task: string) {
if (pendingChanges.has(task)) return;
pendingChanges.set(
task,
setTimeout(() => {
pendingChanges.delete(task);
void refreshChanges(task);
}, 500),
);
}
async function refreshProviders() {
const providers = await call(commands.agentProviders()).catch(() => null);
if (providers) useAgents.setState({ providers });
}
export function openTask(task: string) {
// The title rides along so a restored tab has it before the tasks load.
const title = useAgents.getState().tasks.get(task)?.title;
void editorService().openInput({ kind: SESSION_KIND, key: sessionKey(task), data: { task, title } }, { focus: true });
}
/** Opens the New Task page; `prompt` pre-fills it. */
export function openHome(prompt?: string) {
if (prompt !== undefined) updateComposer(HOME, { text: prompt });
void editorService().openInput({ kind: HOME_KIND, key: HOME_KEY, data: {} }, { focus: true });
}
export async function startTask(): Promise<void> {
const { draft } = useAgents.getState();
const composer = composerDraft(HOME);
if (!canSend(composer)) return;
const task = await call(
commands.agentCreate({
prompt: toPrompt(composer),
provider: draft.provider,
config: Object.entries(draft.config).map(([id, value]) => ({ id, value })),
isolation: draft.isolation,
base: draft.isolation === "worktree" ? draft.base : null,
}),
);
// Its events can arrive before this reply: keep the newer state they carried.
useAgents.setState((s) => ({
tasks: s.tasks.has(task.id) ? s.tasks : new Map(s.tasks).set(task.id, task),
entries: new Map(s.entries).set(task.id, s.entries.get(task.id) ?? []),
}));
remember(composer.text.trim());
updateComposer(HOME, emptyDraft());
editorService().closeInput?.(HOME_KEY);
openTask(task.id);
}
function remember(text: string) {
if (!text) return;
const history = [text, ...useAgents.getState().history.filter((h) => h !== text)].slice(0, HISTORY_SIZE);
useAgents.setState({ history });
save(HISTORY_KEY, history);
}
/** Sends the session composer's message: queued while the agent works, or with `now` interrupting it. */
export async function sendMessage(task: string, now: boolean): Promise<void> {
const composer = composerDraft(task);
if (!canSend(composer)) return;
await call(commands.agentPrompt(task, toPrompt(composer), now));
updateComposer(task, emptyDraft());
}
/** Takes a queued message back into the composer to edit it. */
export async function editQueued(task: string, queued: string): Promise<void> {
const item = await call(commands.agentUnqueue(task, queued));
const current = composerDraft(task);
const cwd = useAgents.getState().tasks.get(task)?.cwd ?? "";
const files: FileRef[] = item.prompt.files.map((path) => ({
path,
rel: path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path,
}));
updateComposer(task, {
text: current.text ? `${item.prompt.text}\n${current.text}` : item.prompt.text,
files: [...files, ...current.files],
images: [...item.prompt.images, ...current.images],
});
}
export const removeQueued = (task: string, queued: string) => call(commands.agentUnqueue(task, queued));
export const sendQueuedNow = (task: string, queued: string) => call(commands.agentSendQueuedNow(task, queued));
export const setTaskConfig = (task: string, option: string, value: string) =>
call(commands.agentSetConfig(task, option, value));
/**
* Adds `@file` references (with an optional line range) to the composer typed in last and
* shows it: IDEA-style "send this to the chat".
*/
export function addReferences(refs: Array<{ path: string; rel: string; mention: string }>) {
const s = useAgents.getState();
const key = s.lastComposer !== HOME && s.tasks.has(s.lastComposer) ? s.lastComposer : HOME;
const task = key === HOME ? null : s.tasks.get(key)!;
const current = composerDraft(key);
// A worktree task sees the same files under its own directory.
const root = task?.isolation === "worktree" ? task.cwd : null;
const files: FileRef[] = refs.map((r) => ({ path: root ? `${root}/${r.rel}` : r.path, rel: r.rel }));
const mentions = refs.map((r) => r.mention).join(" ");
const text = current.text && !/\s$/.test(current.text) ? `${current.text} ${mentions} ` : `${current.text}${mentions} `;
updateComposer(key, { text, files: [...current.files, ...files] });
if (task) openTask(task.id);
else openHome();
}
export const cancelTask = (task: string) => call(commands.agentCancel(task));
export const respond = (task: string, permission: number, option: string | null) =>
call(commands.agentRespond(task, permission, option));
export const archiveTask = (task: string) => call(commands.agentArchive(task));
export const deleteTask = (task: string, removeWorktree: boolean) => call(commands.agentDelete(task, removeWorktree));
+2 -1
Переглянути файл
@@ -28,6 +28,7 @@ import {
type StructuralForm,
} from "./structural";
import styles from "./Analysis.module.css";
import { tx } from "@/platform/i18n";
function InspectCodeDialog({ onClose }: { onClose: () => void }) {
const [scope, setScope] = useState<ScopeChoice>(() => defaultScope());
@@ -363,7 +364,7 @@ function ReplacePreviewDialog({ files, onClose }: { files: StructuralFile[]; onC
<Checkbox
checked={!excluded.has(key)}
onChange={(on) => toggle(key, on)}
aria-label={`Replace in ${f.relPath}:${m.range.start.line + 1}`}
aria-label={tx`Replace in ${f.relPath}:${m.range.start.line + 1}`}
/>
<div className={styles.previewBody}>
<span className={styles.previewWhere}>
+2 -1
Переглянути файл
@@ -11,6 +11,7 @@ import { maybeEditorService, notifications } from "@/workbench/contributions";
import { confirm } from "@/workbench/messages";
import { removeTask, upsertTask } from "@/workbench/tasks";
import { changedFiles, currentFile, type ScopeChoice } from "./scope";
import { tx } from "@/platform/i18n";
export interface CleanupOptions {
fixAll: boolean;
@@ -97,7 +98,7 @@ export async function runCodeCleanup(choice: ScopeChoice, options: CleanupOption
}
if (
files.length > CONFIRM_ABOVE &&
!(await confirm({ title: "Code Cleanup", message: `Clean up ${files.length} files?`, ok: "Clean Up" }))
!(await confirm({ title: "Code Cleanup", message: tx`Clean up ${files.length} files?`, ok: "Clean Up" }))
)
return;
const editors = maybeEditorService();
+2 -1
Переглянути файл
@@ -21,6 +21,7 @@ import {
} from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
import { resolveScope, type ScopeChoice } from "./scope";
import { tx } from "@/platform/i18n";
const STATE_KEY = "openidea.analysis.structural.v1";
@@ -152,7 +153,7 @@ export async function showStructuralResults(
group: "analysis",
level: "warning",
title: "Search Structurally",
body: `Stopped after ${summary.matches} matches`,
body: tx`Stopped after ${summary.matches} matches`,
});
}
}
+2 -2
Переглянути файл
@@ -11,7 +11,7 @@ import { executeAction, getAction, isActionEnabled, useActions } from "@/platfor
import { useContextKeys } from "@/platform/context";
import { keysWithPrefix, t, tx, useI18n } from "@/platform/i18n";
import { commands, isTauri, type DynamicItem, type MenuConfig, type MenuNode } from "@/platform/ipc";
import { bindingsFor, effectiveBindings, formatBinding, useKeymap } from "@/platform/keymap";
import { effectiveBindings, formatBinding, primaryKeyBinding, useKeymap } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { useContributions } from "@/workbench/contributions";
import { isDistractionFree, useDistractionFree } from "@/workbench/distractionFree";
@@ -48,7 +48,7 @@ export function buildMenuConfig(os: HostOs): MenuConfig {
const shortcuts: Record<string, string> = {};
for (const [, id] of effectiveBindings()) {
if (id in shortcuts) continue;
const first = bindingsFor(id)[0];
const first = primaryKeyBinding(id);
if (first) shortcuts[id] = menuShortcut(first, os);
}
return { locale: useI18n.getState().locale, labels, shortcuts, dynamic: dynamicMenuItems() };
+2 -1
Переглянути файл
@@ -10,6 +10,7 @@ import { useProject } from "@/platform/project";
import { notifications, registerEditorBanner } from "@/workbench/contributions";
import { promptInput } from "@/workbench/inputDialogs";
import { showNewProject } from "./NewProject";
import { tx } from "@/platform/i18n";
/** localStorage prefixes of IDE-level state exported with the settings (templates, macros, layouts). */
export const EXPORTED_STORAGE = [
@@ -93,7 +94,7 @@ async function renameProject() {
initial: project.name,
okLabel: "Rename",
validate: () => null,
hint: `Leave empty to use the folder name (${folder}).`,
hint: tx`Leave empty to use the folder name (${folder}).`,
});
if (name === null) return;
try {
+8 -3
Переглянути файл
@@ -43,6 +43,7 @@ import {
useBuild,
} from "./store";
import styles from "./BuildTools.module.css";
import { tx } from "@/platform/i18n";
export function MavenView() {
return <BuildToolView tool="maven" />;
@@ -152,6 +153,7 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
action={`${tool}.toggleOffline`}
pressed={maven ? settings.maven.offline : settings.gradle.offline}
/>
{maven && <IconButton icon="build-dependencies" size="small" iconSize={16} action="maven.analyzeDependencies" />}
{maven && (
<IconButton
icon="skip-tests"
@@ -291,8 +293,8 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
const config = isRunnable(node) && model ? nodeConfig(nodes.filter(isRunnable), model) : null;
if (config) {
items.push(
{ label: `Run '${config.name}'`, icon: "run", run: () => runConfig(config, "run") },
{ label: `Debug '${config.name}'`, icon: "debug", run: () => runConfig(config, "debug") },
{ label: tx`Run '${config.name}'`, icon: "run", run: () => runConfig(config, "run") },
{ label: tx`Debug '${config.name}'`, icon: "debug", run: () => runConfig(config, "debug") },
{ kind: "separator" },
);
if (node.kind === "runConfig")
@@ -308,13 +310,16 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
},
{ kind: "action", action: "maven.reload", label: "Reload All Maven Projects" },
{ kind: "action", action: "maven.executeGoal" },
{ kind: "action", action: "maven.analyzeDependencies", arg: { pom: node.project.pom } },
);
}
if (node.kind === "dependencies")
items.push({ kind: "action", action: "maven.analyzeDependencies", arg: { pom: node.project.pom } });
if (node.kind === "gradleProject") {
const file = node.project.buildFile;
if (file)
items.push({
label: `Open ${file.split("/").pop()}`,
label: tx`Open ${file.split("/").pop()}`,
icon: "file-gradle",
run: () => void editorService().openFile(`${root}/${file}`),
});
+205
Переглянути файл
@@ -0,0 +1,205 @@
.view {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--bg-editor);
}
.toolbar {
flex: none;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-height: 36px;
padding: 4px 8px;
border-bottom: 1px solid var(--border);
}
.module {
min-width: 160px;
max-width: 260px;
}
.search {
flex: 1 1 200px;
min-width: 140px;
max-width: 360px;
}
.scope {
min-width: 110px;
}
.sep {
width: 1px;
height: 16px;
margin: 0 2px;
background: var(--border);
}
.banner {
flex: none;
display: flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 2px 10px;
font-size: var(--fs-small);
color: var(--fg-secondary);
border-bottom: 1px solid var(--border);
}
.bannerText {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
flex: 1;
min-height: 0;
display: flex;
}
.body > * {
flex: 1;
min-width: 0;
}
.panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.panel > * {
flex: 1;
min-height: 0;
}
.row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
white-space: nowrap;
}
.icon {
flex: none;
}
.label {
flex: 0 1 auto;
min-width: 24px;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg);
}
.selectedLabel {
color: var(--fg-strong);
}
.version {
flex: none;
font-family: var(--font-mono);
font-size: var(--fs-small);
color: var(--fg-secondary);
}
.detail {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.conflict {
color: var(--warning);
}
.omitted .label,
.omitted .version {
color: var(--fg-muted);
}
.usages {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.usagesHeader {
flex: none;
display: flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 10px;
border-bottom: 1px solid var(--border);
}
.usagesTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
color: var(--fg-strong);
}
.conflictNote {
flex: none;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: var(--fs-small);
color: var(--fg-secondary);
background: var(--bg-feedback-warning);
border-bottom: 1px solid var(--border-feedback-warning);
}
.usageList {
flex: 1;
min-height: 0;
overflow: auto;
padding: 4px 0;
}
.usage {
padding: 6px 10px 8px;
border-bottom: 1px solid var(--border);
}
.usageHeader {
display: flex;
align-items: center;
gap: 6px;
min-height: 26px;
}
.usageVersion {
flex: none;
font-family: var(--font-mono);
color: var(--fg);
}
.usageStatus {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--fs-small);
color: var(--fg-muted);
}
.spacer {
flex: 1;
}
.path {
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.step {
display: flex;
align-items: center;
gap: 6px;
min-height: 22px;
white-space: nowrap;
}
.stepLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-secondary);
}
.stepTarget {
color: var(--fg-strong);
}
+118
Переглянути файл
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import type { BuildModel, MavenDependency } from "@/platform/ipc";
import { menuGroupItems, provideEditorService, type EditorService } from "@/workbench/contributions";
import { DependencyAnalyzer } from "./DependencyAnalyzer";
import { activate } from "./index";
import { ANALYZER_KIND, useAnalyzer, useBuild } from "./store";
const ipc = vi.hoisted(() => ({ exclude: vi.fn(), declaration: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
call: (value: unknown) => value,
commands: { ...real.commands, mavenExcludeDependency: ipc.exclude, mavenDependencyDeclaration: ipc.declaration },
};
});
function dep(coords: string, extra: Partial<MavenDependency> = {}, children: MavenDependency[] = []): MavenDependency {
const [groupId, artifactId, version] = coords.split(":");
return {
groupId: groupId!,
artifactId: artifactId!,
version: version!,
type: "jar",
classifier: null,
scope: "compile",
optional: false,
children,
...extra,
};
}
const model: BuildModel = {
maven: {
projects: [
{
pom: "pom.xml",
dir: "",
groupId: "demo",
artifactId: "conflict-demo",
version: "1.0",
packaging: "jar",
displayName: "conflict-demo",
parent: null,
modules: [],
aggregated: false,
plugins: [],
dependencies: [
dep("com.google.guava:guava:32.1.3-jre"),
dep("com.google.inject:guice:5.1.0", {}, [
dep("com.google.guava:guava:30.1-jre", { omitted: "conflict", conflictWith: "32.1.3-jre" }),
]),
],
profiles: [],
javaVersion: 17,
command: "mvn",
options: [],
env: {},
},
],
profiles: [],
syncedAt: 1,
stale: false,
syncError: null,
},
gradle: null,
};
describe("dependency analyzer", () => {
let dispose: () => void = () => {};
const openInput = vi.fn(() => Promise.resolve());
const openFile = vi.fn(() => Promise.resolve());
beforeEach(() => {
provideEditorService({ openInput, openFile, activePath: () => null } as unknown as EditorService);
setContext("projectOpen", true);
dispose = activate();
act(() => useBuild.setState({ root: "/p", model }));
});
afterEach(() => {
dispose();
setContext("projectOpen", false);
vi.clearAllMocks();
});
it("opens as an editor tab from the action and from a pom.xml's menus", async () => {
await executeAction("maven.analyzeDependencies", { pom: "pom.xml" });
expect(openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: ANALYZER_KIND }), { focus: true });
expect(useAnalyzer.getState().pom).toBe("pom.xml");
for (const menu of ["editor", "projectView", "editorTab"] as const) {
const items = menuGroupItems(menu, { paths: ["/p/pom.xml"] });
expect(items[0]).toMatchObject({ action: "maven.analyzeDependencies", arg: { pom: "pom.xml" } });
}
expect(menuGroupItems("editor", { paths: ["/p/other/pom.xml"] })).toEqual([]);
expect(menuGroupItems("editor", { paths: ["/p/src/Main.java"] })).toEqual([]);
});
it("marks conflicts, shows their usages and excludes through the direct dependency", async () => {
ipc.exclude.mockResolvedValue({ path: "/p/pom.xml", range: {} });
render(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
fireEvent.click(screen.getByRole("checkbox", { name: "Show conflicts only" }));
expect(screen.queryByText("com.google.inject:guice")).toBeNull();
fireEvent.mouseDown(screen.getByText("com.google.guava:guava").closest("[role=treeitem]")!, { button: 0 });
expect(screen.getByText("omitted for conflict with 32.1.3-jre")).toBeTruthy();
expect(screen.getByText("com.google.inject:guice:5.1.0")).toBeTruthy();
// Only the transitive usage can be excluded.
const exclude = screen.getAllByRole("button", { name: "Exclude" });
expect(exclude).toHaveLength(1);
await act(async () => fireEvent.click(exclude[0]!));
expect(ipc.exclude).toHaveBeenCalledWith(
"pom.xml",
{ groupId: "com.google.inject", artifactId: "guice" },
{ groupId: "com.google.guava", artifactId: "guava" },
);
});
});
+420
Переглянути файл
@@ -0,0 +1,420 @@
/**
* Dependency Analyzer editor tab: a Maven module's resolved artifacts (flat list or
* the transitive tree) with conflicts marked, and for the selected artifact every
* path that brings it in, with Exclude / Jump to Source / Copy Coordinates.
*/
import { useMemo, useState, type MouseEvent } from "react";
import clsx from "clsx";
import { call, commands, type MavenDependency, type MavenProject } from "@/platform/ipc";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { editorService, notifications, type EditorInput } from "@/workbench/contributions";
import {
Button,
Checkbox,
EmptyState,
Icon,
IconButton,
SearchField,
Select,
SplitGroup,
SplitHandle,
SplitPanel,
Spinner,
T,
Tree,
copyText,
showContextMenu,
type MenuItem,
type SelectOption,
} from "@/ui";
import {
analyze,
artifactKey,
coordinates,
exclusionFor,
filterArtifacts,
filterTree,
scopesOf,
sortedUsages,
statusText,
usageStatus,
type AnalyzedArtifact,
type DependencyTreeNode,
type DependencyUsage,
} from "./analyzer";
import { syncTool, useAnalyzer, useBuild } from "./store";
import styles from "./DependencyAnalyzer.module.css";
type Mode = "list" | "tree";
type Node =
| { kind: "artifact"; key: string; artifact: AnalyzedArtifact }
| { kind: "node"; key: string; node: DependencyTreeNode; children: Node[] };
const ALL_SCOPES = "*";
function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
}
async function jumpToDeclaration(pom: string, d: MavenDependency) {
try {
const loc = await call(commands.mavenDependencyDeclaration(pom, { groupId: d.groupId, artifactId: d.artifactId }));
const { start, end } = loc.range;
await editorService().openFile(loc.path, {
focus: true,
selection: { line: start.line, col: start.col, endLine: end.line, endCol: end.col },
});
} catch (e) {
notifications().notify({
level: "warning",
group: "tools",
title: "Cannot find the declaration",
body: errorText(e),
});
}
}
async function exclude(pom: string, usage: DependencyUsage) {
const target = exclusionFor(usage);
if (!target) return;
const what = `${target.exclusion.groupId}:${target.exclusion.artifactId}`;
try {
await call(commands.mavenExcludeDependency(pom, target.dependency, target.exclusion));
} catch (e) {
notifications().notify({ level: "error", group: "tools", title: `Cannot exclude ${what}`, body: errorText(e) });
return;
}
await syncTool("maven");
}
function versionLabel(d: MavenDependency) {
return d.version ?? "";
}
function moduleOptions(projects: readonly MavenProject[]): SelectOption[] {
return projects
.filter((p) => p.packaging !== "pom" || p.dependencies.length > 0)
.map((p) => ({ value: p.pom, label: p.displayName, detail: p.pom, icon: "maven-project" }));
}
function ArtifactRow({ artifact, selected }: { artifact: AnalyzedArtifact; selected: boolean }) {
const tx = useTx();
return (
<span className={styles.row}>
<Icon name={artifact.conflict ? "warning" : "library"} size={16} className={styles.icon} />
<span className={clsx(styles.label, artifact.conflict && styles.conflict, selected && styles.selectedLabel)}>
{artifact.key}
</span>
<span className={styles.version}>{artifact.version ?? tx("omitted")}</span>
{artifact.conflict && (
<span className={styles.detail}>{artifact.requested.filter((v) => v !== artifact.version).join(", ")}</span>
)}
{artifact.scope !== "compile" && <span className={styles.detail}>({artifact.scope})</span>}
</span>
);
}
function TreeRow({ node, conflict, selected }: { node: DependencyTreeNode; conflict: boolean; selected: boolean }) {
const tx = useTxTemplate();
const d = node.dependency;
const status = statusText(usageStatus(d));
return (
<span className={clsx(styles.row, d.omitted != null && styles.omitted)}>
<Icon name={conflict ? "warning" : "library"} size={16} className={styles.icon} />
<span className={clsx(styles.label, conflict && styles.conflict, selected && styles.selectedLabel)}>
{artifactKey(d)}
</span>
<span className={styles.version}>{versionLabel(d)}</span>
{d.scope !== "compile" && <span className={styles.detail}>({d.scope})</span>}
{status && <span className={styles.detail}>{tx(status)}</span>}
</span>
);
}
function UsageBlock({ usage, module, pom }: { usage: DependencyUsage; module: MavenProject; pom: string }) {
const tx = useTxTemplate();
const status = usageStatus(usage.dependency);
const text = statusText(status);
const direct = usage.path[0]!;
const canExclude = !!exclusionFor(usage);
return (
<section className={styles.usage} aria-label={coordinates(usage.dependency)}>
<div className={styles.usageHeader}>
<Icon
name={status.kind === "conflict" ? "warning" : status.kind === "resolved" ? "success" : "info"}
size={16}
className={styles.icon}
/>
<span className={styles.usageVersion}>{status.version}</span>
<span className={clsx(styles.usageStatus, status.kind === "conflict" && styles.conflict)}>
{text ? tx(text) : tx("resolved")}
</span>
<span className={styles.spacer} />
{canExclude && (
<Button size="small" variant="ghost" onClick={() => void exclude(pom, usage)}>
Exclude
</Button>
)}
<IconButton
icon="autoscroll-to-source"
size="small"
title="Jump to Source"
onClick={() => void jumpToDeclaration(pom, direct)}
/>
</div>
<ol className={styles.path}>
<li className={styles.step} style={{ paddingLeft: 0 }}>
<Icon name="maven-project" size={16} className={styles.icon} />
<span className={styles.stepLabel}>{module.displayName}</span>
</li>
{usage.path.map((d, i) => (
<li key={i} className={styles.step} style={{ paddingLeft: (i + 1) * 16 }}>
<Icon name="library" size={16} className={styles.icon} />
<span className={clsx(styles.stepLabel, i === usage.path.length - 1 && styles.stepTarget)}>
{coordinates(d)}
</span>
{d.scope !== "compile" && <span className={styles.detail}>({d.scope})</span>}
</li>
))}
</ol>
</section>
);
}
function UsagesPane({
artifact,
module,
pom,
}: {
artifact: AnalyzedArtifact | null;
module: MavenProject;
pom: string;
}) {
if (!artifact) return <EmptyState title="Select a dependency to see its usages" />;
const usages = sortedUsages(artifact.usages);
const coords = coordinates({ ...artifact, version: artifact.version });
return (
<div className={styles.usages}>
<div className={styles.usagesHeader}>
<span className={clsx(styles.usagesTitle, artifact.conflict && styles.conflict)}>{coords}</span>
<IconButton icon="copy" size="small" title="Copy Coordinates" onClick={() => void copyText(coords)} />
</div>
{artifact.conflict && (
<div className={styles.conflictNote} role="note">
<Icon name="warning" size={16} />
<span>
<T>Versions requested</T>: {artifact.requested.join(", ")}
</span>
</div>
)}
<div className={styles.usageList} aria-label="Usages">
{usages.map((u, i) => (
<UsageBlock key={i} usage={u} module={module} pom={pom} />
))}
</div>
</div>
);
}
export function DependencyAnalyzer(_props: { input: EditorInput }) {
const tx = useTx();
const maven = useBuild((s) => s.model?.maven ?? null);
const syncing = useBuild((s) => s.syncing.maven);
const chosen = useAnalyzer((s) => s.pom);
const [query, setQuery] = useState("");
const [conflictsOnly, setConflictsOnly] = useState(false);
const [scope, setScope] = useState<string>(ALL_SCOPES);
const [mode, setMode] = useState<Mode>("list");
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const options = useMemo(() => moduleOptions(maven?.projects ?? []), [maven]);
const pom = options.some((o) => o.value === chosen) ? chosen! : (options[0]?.value ?? null);
const module = maven?.projects.find((p) => p.pom === pom) ?? null;
const artifacts = useMemo(() => analyze(module?.dependencies ?? []), [module]);
const conflicted = useMemo(() => new Set(artifacts.filter((a) => a.conflict).map((a) => a.key)), [artifacts]);
const filter = useMemo(
() => ({ query, conflictsOnly, scope: scope === ALL_SCOPES ? null : scope }),
[query, conflictsOnly, scope],
);
const listed = useMemo(() => filterArtifacts(artifacts, filter), [artifacts, filter]);
const tree = useMemo(() => filterTree(module?.dependencies ?? [], filter, conflicted), [module, filter, conflicted]);
const roots: Node[] = useMemo(() => {
const toNode = (node: DependencyTreeNode): Node => ({
kind: "node",
key: `n:${node.key}`,
node,
children: node.children.map(toNode),
});
return mode === "list"
? listed.map((artifact) => ({ kind: "artifact", key: `a:${artifact.key}`, artifact }))
: tree.map(toNode);
}, [mode, listed, tree]);
// A narrowing filter opens the tree down to its matches until the user expands or collapses.
const filterId = `${mode}|${filter.query}|${filter.conflictsOnly}|${filter.scope}|${pom}`;
const [manual, setManual] = useState<{ id: string; keys: Set<string> } | null>(null);
const expanded = useMemo(() => {
if (manual?.id === filterId) return manual.keys;
const keys = new Set<string>();
if (filter.conflictsOnly || filter.scope != null || filter.query.trim()) {
const walk = (nodes: readonly Node[]) => {
for (const n of nodes)
if (n.kind === "node" && n.children.length) {
keys.add(n.key);
walk(n.children);
}
};
walk(roots);
}
return keys;
}, [manual, filterId, filter, roots]);
const selected = artifacts.find((a) => a.key === selectedKey) ?? null;
const scopeOptions: SelectOption[] = [
{ value: ALL_SCOPES, label: tx("All scopes") },
...scopesOf(artifacts).map((s) => ({ value: s, label: s })),
];
const nodeArtifactKey = (n: Node) => (n.kind === "artifact" ? n.artifact.key : artifactKey(n.node.dependency));
/** The usage a row stands for: a tree node's own, else the artifact's direct (or first) one. */
const usageOf = (n: Node): DependencyUsage | undefined => {
if (n.kind === "node")
return artifacts
.find((a) => a.key === nodeArtifactKey(n))
?.usages.find((u) => u.dependency === n.node.dependency);
return n.artifact.usages.find((u) => u.path.length === 1) ?? n.artifact.usages[0];
};
const contextMenu = (n: Node | null, _sel: Set<string>, e: MouseEvent) => {
if (!n || !pom) return;
e.preventDefault();
const usage = usageOf(n);
if (!usage) return;
const items: MenuItem[] = [
{ label: "Jump to Source", icon: "autoscroll-to-source", run: () => void jumpToDeclaration(pom, usage.path[0]!) },
];
if (exclusionFor(usage)) items.push({ label: "Exclude", run: () => void exclude(pom, usage) });
items.push({ label: "Copy Coordinates", icon: "copy", run: () => void copyText(coordinates(usage.dependency)) });
showContextMenu({ x: e.clientX, y: e.clientY }, items);
};
if (!maven) return <EmptyState title="No Maven projects" />;
return (
<div className={styles.view}>
<div className={styles.toolbar} role="toolbar" aria-label="Dependency Analyzer">
<Select
aria-label="Module"
className={styles.module}
value={pom}
options={options}
onChange={(v) => {
useAnalyzer.setState({ pom: v });
setSelectedKey(null);
}}
/>
<SearchField
className={styles.search}
value={query}
onChange={setQuery}
placeholder={tx("Search dependencies")}
aria-label="Search dependencies"
clearable
/>
<Checkbox checked={conflictsOnly} onChange={setConflictsOnly} label="Show conflicts only" />
<Select aria-label="Scope" className={styles.scope} value={scope} options={scopeOptions} onChange={setScope} />
<span className={styles.sep} />
<IconButton
icon="list"
size="small"
iconSize={16}
title="Show as List"
pressed={mode === "list"}
onClick={() => setMode("list")}
/>
<IconButton
icon="hierarchy"
size="small"
iconSize={16}
title="Show as Tree"
pressed={mode === "tree"}
onClick={() => setMode("tree")}
/>
<span className={styles.sep} />
{syncing ? (
<IconButton icon="stop" size="small" iconSize={16} action="maven.cancelReload" />
) : (
<IconButton icon="refresh" size="small" iconSize={16} action="maven.reload" />
)}
</div>
{syncing ? (
<div className={styles.banner} role="status">
<Spinner size={12} />
<span className={styles.bannerText}>{syncing.message}</span>
</div>
) : maven.stale && maven.syncedAt != null ? (
<div className={styles.banner}>
<Icon name="maven-load-changes" size={16} />
<span className={styles.bannerText}>
<T>Dependency trees may be out of date</T>
</span>
<Button size="small" variant="ghost" onClick={() => void syncTool("maven")}>
Load Maven Changes
</Button>
</div>
) : null}
{maven.syncedAt == null ? (
<EmptyState
title="Dependency trees are read when Maven projects are reloaded"
actions={
<Button size="small" onClick={() => void syncTool("maven")} disabled={!!syncing}>
Reload All Maven Projects
</Button>
}
/>
) : !module || !pom ? (
<EmptyState title="No Maven modules" />
) : (
<div className={styles.body}>
<SplitGroup orientation="horizontal" id="maven-dependency-analyzer-split">
<SplitPanel id="dependencies" minSize={200} className={styles.panel}>
<Tree<Node>
aria-label="Dependencies"
roots={roots}
getKey={(n) => n.key}
getLabel={(n) => (n.kind === "artifact" ? n.artifact.key : artifactKey(n.node.dependency))}
getChildren={(n) => (n.kind === "node" ? n.children : undefined)}
isExpandable={(n) => n.kind === "node" && n.node.children.length > 0}
expanded={expanded}
onExpandedChange={(next) => setManual({ id: filterId, keys: next })}
onSelectionChange={(_keys, lead) => setSelectedKey(lead ? nodeArtifactKey(lead) : null)}
renderNode={(n, state) =>
n.kind === "artifact" ? (
<ArtifactRow artifact={n.artifact} selected={state.selected} />
) : (
<TreeRow
node={n.node}
conflict={conflicted.has(artifactKey(n.node.dependency))}
selected={state.selected}
/>
)
}
onActivate={(n) => {
const usage = usageOf(n);
if (usage) void jumpToDeclaration(pom, usage.path[0]!);
}}
onContextMenu={contextMenu}
multiSelect={false}
empty={<EmptyState title={artifacts.length ? "No dependencies match the filter" : "No dependencies"} />}
/>
</SplitPanel>
<SplitHandle />
<SplitPanel id="usages" minSize={240} className={styles.panel}>
<UsagesPane artifact={selected} module={module} pom={pom} />
</SplitPanel>
</SplitGroup>
</div>
)}
</div>
);
}
+1
Переглянути файл
@@ -70,6 +70,7 @@ describe("build tools feature", () => {
it("offers its actions in Search Everywhere only for Maven projects and toggles run options", async () => {
const visible = () => allActions().filter((a) => !a.hidden && a.id.startsWith("maven."));
expect(visible().map((a) => a.id).sort()).toEqual([
"maven.analyzeDependencies",
"maven.executeGoal",
"maven.reload",
"maven.toggleOffline",
+124
Переглянути файл
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import type { MavenDependency } from "@/platform/ipc";
import {
analyze,
coordinates,
exclusionFor,
filterArtifacts,
filterTree,
scopesOf,
sortedUsages,
statusText,
usageStatus,
type AnalyzerFilter,
} from "./analyzer";
function dep(coords: string, extra: Partial<MavenDependency> = {}, children: MavenDependency[] = []): MavenDependency {
const [groupId, artifactId, version, scope = "compile"] = coords.split(":");
return {
groupId: groupId!,
artifactId: artifactId!,
version: version!,
type: "jar",
classifier: null,
scope,
optional: false,
children,
...extra,
};
}
/** The tree `dependency:tree -Dverbose` printed for the conflict demo project. */
const TREE: MavenDependency[] = [
dep("org.hamcrest:hamcrest-core:1.3"),
dep("org.apache.commons:commons-text:1.10.0", {}, [dep("org.apache.commons:commons-lang3:3.12.0")]),
dep("org.apache.commons:commons-configuration2:2.10.1", {}, [
dep("org.apache.commons:commons-lang3:3.14.0", { omitted: "conflict", conflictWith: "3.12.0" }),
dep("org.apache.commons:commons-text:1.11.0", { omitted: "conflict", conflictWith: "1.10.0" }),
dep("commons-logging:commons-logging:1.2", { managedFrom: "1.3.0" }),
]),
dep("com.google.guava:guava:32.1.3-jre", {}, [dep("com.google.guava:failureaccess:1.0.1")]),
dep("com.google.inject:guice:5.1.0", {}, [
dep("javax.inject:javax.inject:1"),
dep("com.google.guava:guava:30.1-jre", { omitted: "conflict", conflictWith: "32.1.3-jre" }),
]),
dep("junit:junit:4.13.2:test", {}, [dep("org.hamcrest:hamcrest-core:1.3:test", { omitted: "duplicate" })]),
];
const ALL: AnalyzerFilter = { query: "", conflictsOnly: false, scope: null };
const byKey = (key: string) => analyze(TREE).find((a) => a.key === key)!;
describe("dependency analyzer", () => {
it("collects every artifact with its resolved version and conflicts", () => {
const artifacts = analyze(TREE);
expect(artifacts.filter((a) => a.conflict).map((a) => a.key)).toEqual([
"com.google.guava:guava",
"org.apache.commons:commons-lang3",
"org.apache.commons:commons-text",
]);
const lang3 = byKey("org.apache.commons:commons-lang3");
expect(lang3).toMatchObject({ version: "3.12.0", requested: ["3.12.0", "3.14.0"], scope: "compile" });
const hamcrest = byKey("org.hamcrest:hamcrest-core");
expect(hamcrest.conflict).toBe(false);
expect(hamcrest.usages).toHaveLength(2);
// Managed versions count the version that was asked for.
expect(byKey("commons-logging:commons-logging").requested).toEqual(["1.3.0"]);
});
it("lists usage paths from the direct dependency, resolved first", () => {
const usages = sortedUsages(byKey("com.google.guava:guava").usages);
expect(usages.map((u) => u.path.map((d) => d.artifactId))).toEqual([["guava"], ["guice", "guava"]]);
expect(usages.map((u) => statusText(usageStatus(u.dependency)))).toEqual([
null,
"omitted for conflict with 32.1.3-jre",
]);
expect(statusText(usageStatus(byKey("commons-logging:commons-logging").usages[0]!.dependency))).toBe(
"version managed from 1.3.0",
);
expect(statusText(usageStatus(dep("a:b:1", { omitted: "duplicate" })))).toBe("omitted for duplicate");
});
it("excludes through the direct dependency that brings an occurrence in", () => {
const [direct, transitive] = sortedUsages(byKey("com.google.guava:guava").usages);
expect(exclusionFor(direct!)).toBeNull();
expect(exclusionFor(transitive!)).toEqual({
dependency: { groupId: "com.google.inject", artifactId: "guice" },
exclusion: { groupId: "com.google.guava", artifactId: "guava" },
});
});
it("filters by conflicts, scope and text (including requested versions)", () => {
const keys = (f: Partial<AnalyzerFilter>) => filterArtifacts(analyze(TREE), { ...ALL, ...f }).map((a) => a.key);
expect(keys({ conflictsOnly: true })).toHaveLength(3);
expect(keys({ scope: "test" })).toEqual(["junit:junit", "org.hamcrest:hamcrest-core"]);
expect(keys({ query: "LANG3" })).toEqual(["org.apache.commons:commons-lang3"]);
expect(keys({ query: "30.1-jre" })).toEqual(["com.google.guava:guava"]);
expect(scopesOf(analyze(TREE))).toEqual(["compile", "test"]);
});
it("keeps the paths to matches in the tree view", () => {
const conflicted = new Set(
analyze(TREE)
.filter((a) => a.conflict)
.map((a) => a.key),
);
expect(filterTree(TREE, ALL, conflicted)).toHaveLength(TREE.length);
const tree = filterTree(TREE, { ...ALL, conflictsOnly: true }, conflicted);
expect(tree.map((n) => n.dependency.artifactId)).toEqual([
"commons-text",
"commons-configuration2",
"guava",
"guice",
]);
expect(tree[1]!.children.map((n) => n.dependency.artifactId)).toEqual(["commons-lang3", "commons-text"]);
expect(tree[3]!.children.map((n) => n.key)).toEqual(["/4/1"]);
expect(filterTree(TREE, { ...ALL, query: "hamcrest", scope: "test" }, conflicted).map((n) => n.key)).toEqual([
"/5",
]);
});
it("formats coordinates with the classifier", () => {
expect(coordinates(dep("g:a:1", { classifier: "linux" }))).toBe("g:a:linux:1");
expect(coordinates(dep("g:a:1"))).toBe("g:a:1");
});
});
+188
Переглянути файл
@@ -0,0 +1,188 @@
/**
* Dependency Analyzer model: the artifacts of a Maven module's dependency tree
* (read with `dependency:tree -Dverbose`, so it also lists the occurrences Maven
* omitted), which of them conflict, and every path that brings them in.
*/
import type { ArtifactKey, MavenDependency } from "@/platform/ipc";
/** One occurrence of an artifact in the tree, with the path from a direct dependency down to it. */
export interface DependencyUsage {
/** `path[0]` is the module's direct dependency, the last element the occurrence. */
path: MavenDependency[];
dependency: MavenDependency;
}
export interface AnalyzedArtifact {
/** `groupId:artifactId[:classifier]`. */
key: string;
groupId: string;
artifactId: string;
classifier: string | null;
/** Version on the classpath; `null` when every occurrence was omitted. */
version: string | null;
/** Scope of the resolved occurrence (else of the first one). */
scope: string;
/** Distinct versions asked for (before conflict resolution and dependency management). */
requested: string[];
/** Several versions were asked for and some lost. */
conflict: boolean;
usages: DependencyUsage[];
}
export interface AnalyzerFilter {
query: string;
conflictsOnly: boolean;
/** `null` = any scope. */
scope: string | null;
}
export const artifactKey = (d: Pick<MavenDependency, "groupId" | "artifactId" | "classifier">) =>
[d.groupId, d.artifactId, d.classifier].filter(Boolean).join(":");
/** `groupId:artifactId[:classifier]:version`. */
export function coordinates(d: Pick<MavenDependency, "groupId" | "artifactId" | "classifier" | "version">): string {
return [d.groupId, d.artifactId, d.classifier, d.version].filter(Boolean).join(":");
}
const isResolved = (d: MavenDependency) => d.omitted == null;
/** The version a usage asked for: before `<dependencyManagement>` replaced it, if it did. */
export const requestedVersion = (d: MavenDependency) => d.managedFrom ?? d.version ?? null;
/** Every artifact of `deps` (a module's dependency tree), sorted by key. */
export function analyze(deps: readonly MavenDependency[]): AnalyzedArtifact[] {
const byKey = new Map<string, AnalyzedArtifact>();
const walk = (list: readonly MavenDependency[], parents: MavenDependency[]) => {
for (const d of list) {
const path = [...parents, d];
const key = artifactKey(d);
let a = byKey.get(key);
if (!a) {
a = {
key,
groupId: d.groupId,
artifactId: d.artifactId,
classifier: d.classifier ?? null,
version: null,
scope: d.scope,
requested: [],
conflict: false,
usages: [],
};
byKey.set(key, a);
}
a.usages.push({ path, dependency: d });
if (isResolved(d) && a.version == null) {
a.version = d.version ?? null;
a.scope = d.scope;
}
const requested = requestedVersion(d);
if (requested && !a.requested.includes(requested)) a.requested.push(requested);
if (d.omitted === "conflict") a.conflict = true;
walk(d.children, path);
}
};
walk(deps, []);
return [...byKey.values()].sort((x, y) => x.key.localeCompare(y.key));
}
/** Scopes used in the tree, in Maven's order. */
export function scopesOf(artifacts: readonly AnalyzedArtifact[]): string[] {
const order = ["compile", "provided", "runtime", "test", "system", "import"];
const found = new Set(artifacts.flatMap((a) => a.usages.map((u) => u.dependency.scope)));
return [...found].sort((x, y) => (order.indexOf(x) + 1 || 99) - (order.indexOf(y) + 1 || 99) || x.localeCompare(y));
}
function matchesQuery(d: Pick<MavenDependency, "groupId" | "artifactId" | "classifier" | "version">, query: string) {
const q = query.trim().toLowerCase();
return !q || coordinates(d).toLowerCase().includes(q);
}
export function filterArtifacts(artifacts: readonly AnalyzedArtifact[], f: AnalyzerFilter): AnalyzedArtifact[] {
return artifacts.filter(
(a) =>
(!f.conflictsOnly || a.conflict) &&
(f.scope == null || a.usages.some((u) => u.dependency.scope === f.scope)) &&
(matchesQuery(a, f.query) || a.requested.some((v) => matchesQuery({ ...a, version: v }, f.query))),
);
}
/** A node of the tree view; `key` is unique (index path). */
export interface DependencyTreeNode {
key: string;
dependency: MavenDependency;
children: DependencyTreeNode[];
}
/**
* The tree view: nodes that pass the filter, with their ancestors (so every match keeps
* its path), and every descendant of a match when no filter narrows them.
*/
export function filterTree(
deps: readonly MavenDependency[],
f: AnalyzerFilter,
conflicted: ReadonlySet<string>,
): DependencyTreeNode[] {
const narrowing = f.conflictsOnly || f.scope != null || !!f.query.trim();
const passes = (d: MavenDependency) =>
(!f.conflictsOnly || conflicted.has(artifactKey(d))) &&
(f.scope == null || d.scope === f.scope) &&
matchesQuery(d, f.query);
const build = (list: readonly MavenDependency[], parent: string): DependencyTreeNode[] =>
list.flatMap((dependency, i) => {
const key = `${parent}/${i}`;
const children = build(dependency.children, key);
return !narrowing || passes(dependency) || children.length ? [{ key, dependency, children }] : [];
});
return build(deps, "");
}
export type UsageStatus =
| { kind: "resolved"; version: string | null; managedFrom: string | null }
| { kind: "conflict"; version: string | null; winner: string | null }
| { kind: "duplicate"; version: string | null }
| { kind: "cycle"; version: string | null };
export function usageStatus(d: MavenDependency): UsageStatus {
const version = d.version ?? null;
switch (d.omitted) {
case "conflict":
return { kind: "conflict", version, winner: d.conflictWith ?? null };
case "duplicate":
return { kind: "duplicate", version };
case "cycle":
return { kind: "cycle", version };
default:
return { kind: "resolved", version, managedFrom: d.managedFrom ?? null };
}
}
/** Human-readable status, e.g. `omitted for conflict with 3.12.0`. */
export function statusText(s: UsageStatus): string | null {
switch (s.kind) {
case "conflict":
return s.winner ? `omitted for conflict with ${s.winner}` : "omitted for conflict";
case "duplicate":
return "omitted for duplicate";
case "cycle":
return "omitted for cycle";
default:
return s.managedFrom ? `version managed from ${s.managedFrom}` : null;
}
}
/** What Exclude on `usage` adds: an exclusion of the occurrence to the direct dependency bringing it in. */
export function exclusionFor(usage: DependencyUsage): { dependency: ArtifactKey; exclusion: ArtifactKey } | null {
const [direct] = usage.path;
if (!direct || usage.path.length < 2) return null;
return {
dependency: { groupId: direct.groupId, artifactId: direct.artifactId },
exclusion: { groupId: usage.dependency.groupId, artifactId: usage.dependency.artifactId },
};
}
/** The resolved usage first, then the ones that lost a conflict, then the rest; shorter paths first. */
export function sortedUsages(usages: readonly DependencyUsage[]): DependencyUsage[] {
const rank = (u: DependencyUsage) => (isResolved(u.dependency) ? 0 : u.dependency.omitted === "conflict" ? 1 : 2);
return [...usages].sort((a, b) => rank(a) - rank(b) || a.path.length - b.path.length);
}
+54 -1
Переглянути файл
@@ -7,14 +7,23 @@ import { registerActions } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { onIdeEvent, type BuildModel, type BuildTool } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { registerOverlay, registerRunAnythingProvider, registerToolWindow } from "@/workbench/contributions";
import {
registerEditorKind,
registerMenuGroup,
registerOverlay,
registerRunAnythingProvider,
registerToolWindow,
} from "@/workbench/contributions";
import { lazyComponent } from "@/ui";
import { GradleView, MavenView } from "./BuildToolView";
import { ExecutePopupHost } from "./ExecutePopup";
import { buildToolsRunAnything } from "./runAnything";
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots } from "./model";
import {
ANALYZER_KIND,
cancelSync,
loadModel,
openAnalyzer,
resetBuild,
setExpanded,
syncTool,
@@ -45,6 +54,19 @@ function openExecute(tool: BuildTool) {
useBuild.setState({ executeOpen: tool });
}
/** Project-relative `pom` of a Maven project from an action argument: `{ pom }`, or `{ path }` of a pom.xml. */
function analyzerPom(arg: unknown): string | null {
const { model, root, current } = useBuild.getState();
const projects = model?.maven?.projects ?? [];
const a = (arg ?? {}) as { pom?: unknown; path?: unknown };
if (typeof a.pom === "string") return a.pom;
if (typeof a.path === "string" && root) {
const rel = a.path.startsWith(`${root}/`) ? a.path.slice(root.length + 1) : a.path;
if (projects.some((p) => p.pom === rel)) return rel;
}
return current.maven ?? null;
}
const synced = new Set<string>();
/** Registers / unregisters the tool windows to match the model; first sync of a never-synced build. */
@@ -85,6 +107,27 @@ export function activate(): () => void {
const disposers: Array<() => void> = [
registerOverlay({ id: "build-tools.execute", component: ExecutePopupHost }),
registerRunAnythingProvider(buildToolsRunAnything),
registerEditorKind({
kind: ANALYZER_KIND,
component: lazyComponent(() => import("./DependencyAnalyzer").then((m) => m.DependencyAnalyzer)),
title: () => "Dependency Analyzer",
icon: () => "build-dependencies",
restorable: true,
}),
// pom.xml in the editor, its tab and the project view: Analyze Dependencies of that module.
registerMenuGroup({
id: "maven.analyzeDependencies",
menus: ["editor", "editorTab", "projectView"],
order: 30,
items: (_menu, { paths, dirs }) => {
const path = paths.length === 1 && !dirs?.[0] ? paths[0]! : null;
if (!path || !path.endsWith("/pom.xml")) return [];
const { model, root } = useBuild.getState();
const pom = root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : null;
if (!pom || !model?.maven?.projects.some((p) => p.pom === pom)) return [];
return [{ kind: "action", action: "maven.analyzeDependencies", arg: { pom }, label: "Analyze Dependencies…" }];
},
}),
useBuild.subscribe((s, prev) => {
if (s.model !== prev.model || s.root !== prev.root) apply(s.root ? s.model : null, registered);
}),
@@ -130,6 +173,16 @@ export function activate(): () => void {
keywords: ["maven", "mvn", "goal", "run anything"],
run: () => openExecute("maven"),
},
{
id: "maven.analyzeDependencies",
title: "Analyze Dependencies…",
description: "Shows a Maven module's dependency tree, version conflicts and where each library comes from",
category: "Maven",
icon: "build-dependencies",
when: MAVEN_WHEN,
keywords: ["maven", "dependency", "dependencies", "conflict", "tree", "exclude", "analyzer"],
run: ({ arg }) => openAnalyzer(analyzerPom(arg)),
},
{
id: "maven.toggleOffline",
title: "Toggle Offline Mode",
+10 -7
Переглянути файл
@@ -181,8 +181,10 @@ export function mavenChildren(node: BuildNode, model: MavenModel, configs: reado
}));
case "dependencies":
return dependencyNodes(node.key, node.dependencies);
case "dependency":
return node.dependency.children.length ? dependencyNodes(node.key, node.dependency.children) : undefined;
case "dependency": {
const kids = dependencyNodes(node.key, node.dependency.children);
return kids.length ? kids : undefined;
}
case "runConfigs":
return node.configs.map((config) => ({ kind: "runConfig", key: `${node.key}:${config.id}`, config }));
default:
@@ -190,12 +192,13 @@ export function mavenChildren(node: BuildNode, model: MavenModel, configs: reado
}
}
/** The resolved dependencies; occurrences Maven omitted are shown by the Dependency Analyzer only. */
function dependencyNodes(parent: string, deps: readonly MavenDependency[]): BuildNode[] {
return deps.map((dependency, i) => ({
kind: "dependency",
key: `${parent}/${dependency.groupId}:${dependency.artifactId}:${i}`,
dependency,
}));
return deps.flatMap((dependency, i) =>
dependency.omitted == null
? [{ kind: "dependency" as const, key: `${parent}/${dependency.groupId}:${dependency.artifactId}:${i}`, dependency }]
: [],
);
}
// ---------------------------------------------------------------------------
+16 -1
Переглянути файл
@@ -22,7 +22,7 @@ import {
} from "@/platform/ipc";
import { downloadJdkActions } from "@/platform/jdk";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { editorService, notifications } from "@/workbench/contributions";
import { removeTask, upsertTask } from "@/workbench/tasks";
import { profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
@@ -232,6 +232,7 @@ export function cancelSync(tool: BuildTool) {
export function resetBuild() {
generation++;
useAnalyzer.setState({ pom: null });
for (const tool of ["maven", "gradle"] as const) {
cancelSync(tool);
removeTask(`build-sync:${tool}`);
@@ -269,3 +270,17 @@ export function setExpanded(tool: BuildTool, expanded: Set<string>) {
export function setCurrentProject(tool: BuildTool, id: string | undefined) {
if (useBuild.getState().current[tool] !== id) useBuild.setState((s) => ({ current: { ...s.current, [tool]: id } }));
}
// ---------------------------------------------------------------------------
// Dependency Analyzer (an editor tab)
export const ANALYZER_KIND = "maven.dependencyAnalyzer";
/** Module the Dependency Analyzer shows (`pom`, project-relative); `null` = the first one. */
export const useAnalyzer = create<{ pom: string | null }>(() => ({ pom: null }));
/** Opens (or focuses) the Dependency Analyzer tab, showing the module `pom` when given. */
export async function openAnalyzer(pom: string | null) {
if (pom) useAnalyzer.setState({ pom });
await editorService().openInput({ kind: ANALYZER_KIND, key: ANALYZER_KIND, data: {} }, { focus: true });
}
+3 -2
Переглянути файл
@@ -27,6 +27,7 @@ import {
type HierarchyNode,
} from "./hierarchy";
import styles from "./HierarchyView.module.css";
import { tx } from "@/platform/i18n";
type Node = { kind: "root"; id: string } | { kind: "item"; id: string; node: HierarchyNode };
@@ -78,7 +79,7 @@ function HierarchyToolbar() {
icon="arrow-up"
size="small"
iconSize={16}
title={`${directionLabel(mode, "incoming")} Hierarchy`}
title={tx`${directionLabel(mode, "incoming")} Hierarchy`}
pressed={direction === "incoming"}
onClick={() => setDirection("incoming")}
/>
@@ -86,7 +87,7 @@ function HierarchyToolbar() {
icon="arrow-down"
size="small"
iconSize={16}
title={`${directionLabel(mode, "outgoing")} Hierarchy`}
title={tx`${directionLabel(mode, "outgoing")} Hierarchy`}
pressed={direction === "outgoing"}
onClick={() => setDirection("outgoing")}
/>
+4 -3
Переглянути файл
@@ -7,6 +7,7 @@ import { downloadJdkActions } from "@/platform/jdk";
import { relativePath, useProject } from "@/platform/project";
import { STATUS_LABEL, restartServer, serversSummary, useServers } from "./servers";
import styles from "./ServerStatus.module.css";
import { tx } from "@/platform/i18n";
export function ServerStatus() {
const map = useServers((s) => s.servers);
@@ -23,7 +24,7 @@ export function ServerStatus() {
<BarButton
ref={ref}
aria-expanded={open}
aria-label={`Language servers: ${summary.text}`}
aria-label={tx`Language servers: ${summary.text}`}
tooltip="Language servers"
onClick={() => setOpen((v) => !v)}
>
@@ -65,7 +66,7 @@ export function ServerStatus() {
) : s.status === "missing" && canInstall ? (
<Button
size="small"
aria-label={`Install ${s.name}`}
aria-label={tx`Install ${s.name}`}
onClick={() => {
setOpen(false);
void executeAction("tools.install", s.name);
@@ -77,7 +78,7 @@ export function ServerStatus() {
<IconButton
icon="refresh"
size="small"
title={`Restart ${s.name}`}
title={tx`Restart ${s.name}`}
disabled={s.status === "missing" || s.status === "starting"}
onClick={() => void restartServer(s.id)}
/>
+4 -5
Переглянути файл
@@ -1,17 +1,16 @@
/** "problems" file decorations: files with errors (and their folders) get IDEA's red underline. */
import { setFileDecorations, type FileDecoration } from "@/workbench/contributions";
import { countSeverities, fileDiagnostics, useProblems, type ProblemsMap } from "./problemsStore";
import { tx } from "@/platform/i18n";
export function problemDecorations(map: ProblemsMap): Map<string, FileDecoration> {
const out = new Map<string, FileDecoration>();
for (const path of map.keys()) {
const c = countSeverities(fileDiagnostics(map, path));
if (!c.error) continue;
out.set(path, {
error: true,
propagate: true,
tooltip: `${c.error} ${c.error === 1 ? "error" : "errors"}${c.warning ? `, ${c.warning} ${c.warning === 1 ? "warning" : "warnings"}` : ""}`,
});
const errors = c.error === 1 ? tx`${c.error} error` : tx`${c.error} errors`;
const warnings = !c.warning ? "" : c.warning === 1 ? tx`${c.warning} warning` : tx`${c.warning} warnings`;
out.set(path, { error: true, propagate: true, tooltip: warnings ? `${errors}, ${warnings}` : errors });
}
return out;
}
+12 -2
Переглянути файл
@@ -24,7 +24,7 @@ vi.mock("@/platform/ipc", async (orig) => {
vi.mock("./candidates", () => ({ filesMentioning: () => filesMentioning() }));
const { _addEditor } = await import("@/editor/api");
const { fetchUsages } = await import("./usages");
const { fetchUsages, incompleteNote } = await import("./usages");
const { LOCAL_NOTE, TEXT_NOTE } = await import("./syntaxUsages");
const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } });
@@ -39,11 +39,21 @@ beforeEach(() => {
describe("Find Usages fallback", () => {
it("uses the server when it answers, even with nothing", async () => {
cmd.lspReferences.mockReturnValueOnce(ok([]));
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [], incomplete: null }));
expect(await fetchUsages(caret)).toEqual({ groups: [], note: null });
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
});
it("searches project-wide under the given request and says when results may be incomplete", async () => {
const usage = { path: "/p/a.ts", range: r(1, 0, 1) };
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [usage], incomplete: "indexing" }));
const res = await fetchUsages(caret, 42 as never);
expect(cmd.lspReferences).toHaveBeenCalledWith(42, 3, { line: 1, col: 0 }, false, true);
expect(res.groups[0]!.usages.length).toBe(1);
expect(res.note).toBe(incompleteNote("indexing", "x"));
expect(incompleteNote("tooManyFiles", "x")).toBe("May be incomplete: too many files mention 'x' to search them all");
});
it("lists a certain local's references when the server is not ready", async () => {
cmd.syntaxLocals.mockReturnValueOnce(
ok({
+47 -9
Переглянути файл
@@ -1,7 +1,7 @@
/** Find Usages (⌥F7 → "usages" tool window) and Show Usages (⌥⌘F7 → popup). */
import { create } from "zustand";
import type { Location } from "@/bindings";
import { commands, isCancelled } from "@/platform/ipc";
import type { Location, ReferencesIncomplete, RequestId } from "@/bindings";
import { commands, isCancelled, nextRequestId } from "@/platform/ipc";
import { toolWindowService } from "@/workbench/contributions";
import {
caretContext,
@@ -119,6 +119,7 @@ export function pinUsageTab(id: number, pinned: boolean) {
}
export function closeUsageTab(id: number) {
cancelSearch(id);
useUsages.setState((s) => {
const i = s.tabs.findIndex((t) => t.id === id);
const tabs = s.tabs.filter((t) => t.id !== id);
@@ -153,15 +154,25 @@ interface FetchedUsages {
note: string | null;
}
/** Why the server's answer may miss usages, for the results' note. */
export function incompleteNote(reason: ReferencesIncomplete, word: string | null): string {
if (reason === "indexing") return "May be incomplete: the language server is still indexing";
return `May be incomplete: too many files mention ${word ? `'${word}'` : "the name"} to search them all`;
}
/**
* The server's references; when it is not ready or fails, the syntax fallback
* (an empty server answer is respected). Without a fallback the server error stands.
* The server's references across the project (files outside its analysis scope
* included); when it is not ready or fails, the syntax fallback (an empty server
* answer is respected). Without a fallback the server error stands.
* Cancel with `commands.cancelRequest(request)`.
*/
export async function fetchUsages(c: CaretContext): Promise<FetchedUsages> {
export async function fetchUsages(c: CaretContext, request: RequestId = nextRequestId()): Promise<FetchedUsages> {
let locs: Location[];
let note: string | null = null;
try {
locs = await lspRequest(() => commands.lspReferences(c.doc, { line: c.line, col: c.col }, false));
const refs = await lspRequest(() => commands.lspReferences(request, c.doc, { line: c.line, col: c.col }, false, true));
locs = refs.locations;
if (refs.incomplete) note = incompleteNote(refs.incomplete, c.word);
} catch (e) {
if (isCancelled(e)) throw e;
let fb = null;
@@ -179,8 +190,22 @@ export async function fetchUsages(c: CaretContext): Promise<FetchedUsages> {
return { groups: groupUsages(locs, files), note };
}
/** In-flight server requests: of tool window searches by search id, and of the popup. */
const inflight = new Map<number, RequestId>();
let popupRequest: RequestId | null = null;
function cancelSearch(id: number) {
const request = inflight.get(id);
if (request === undefined) return;
inflight.delete(id);
void commands.cancelRequest(request).catch(() => {});
}
/** The window switched projects: results (and in-flight searches) of the previous one are dropped. */
export function resetUsages() {
for (const id of [...inflight.keys()]) cancelSearch(id);
if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {});
popupRequest = null;
useUsages.setState({ tabs: [], active: null });
useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null });
}
@@ -195,15 +220,22 @@ export async function findUsages() {
const id = ++searchSeq;
const base = { id, symbol: c.word, rerun: () => void run(id) };
const loading = { ...base, loading: true, groups: [], error: null, note: null };
if (prev !== null) cancelSearch(prev);
if (prev === null || !replaceUsageTab(prev, loading)) openUsageTab(loading);
toolWindowService().show("usages");
const request = nextRequestId();
inflight.set(id, request);
try {
const { groups, note } = await fetchUsages(c);
const { groups, note } = await fetchUsages(c, request);
replaceUsageTab(id, { ...base, loading: false, groups, error: null, note });
} catch (e) {
// Cancelled here (closed tab, rerun): a newer state already replaced it.
if (isCancelled(e) && !inflight.has(id)) return;
const message = e instanceof Error ? e.message : String(e);
if (!replaceUsageTab(id, { ...base, loading: false, groups: [], error: message, note: null })) return;
reportError(e);
} finally {
if (inflight.get(id) === request) inflight.delete(id);
}
};
await run(null);
@@ -232,11 +264,15 @@ export async function showUsagesPopup() {
hint("Open a file to show usages");
return;
}
if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {});
const request = nextRequestId();
popupRequest = request;
try {
const { groups, note } = await fetchUsages(c);
const { groups, note } = await fetchUsages(c, request);
const total = usageCount(groups);
if (!total) {
hint(c.word ? `No usages of '${c.word}' found` : "No usages found");
const none = c.word ? `No usages of '${c.word}' found` : "No usages found";
hint(note ? `${none}. ${note}` : none);
return;
}
if (total === 1) {
@@ -249,5 +285,7 @@ export async function showUsagesPopup() {
useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word, groups, note });
} catch (e) {
reportError(e);
} finally {
if (popupRequest === request) popupRequest = null;
}
}
+1 -1
Переглянути файл
@@ -6,7 +6,7 @@ const loc = { path: "/p/b.ts", range: { start: { line: 1, col: 4 }, end: { line:
vi.mock("./ipc", async (orig) => ({
...(await orig<typeof import("./ipc")>()),
caretContext: () => ({ doc: 1, path: "/p/a.ts", line: 0, col: 0, word: "used", view: null }),
lspRequest: async () => [loc],
lspRequest: async () => ({ locations: [loc], incomplete: null }),
readManyFileLines: async () => new Map([["/p/b.ts", ["", " used();"]]]),
openLocation: async (path: string, line: number) => void calls.push(`open ${path}:${line}`),
hint: (text: string) => void calls.push(`hint ${text}`),
+2 -1
Переглянути файл
@@ -7,6 +7,7 @@ import { call, commands, isTauri, type InstallCommand, type RunConfig } from "@/
import { notifications, toolWindowService, type ExecutorLaunch, type RunExecutorContribution } from "@/workbench/contributions";
import { coverageSupports, formatPercent } from "./model";
import { addSuite } from "./store";
import { tx } from "@/platform/i18n";
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
@@ -47,7 +48,7 @@ export function notifyMissingTool(
level: "warning",
key: `${group}-missing-${tool}`,
title: `${tool} is not installed`,
body: line ? `${missing.message} Install it with: ${line}` : missing.homepage ? `${missing.message} ${missing.homepage}` : missing.message,
body: line ? tx`${missing.message} Install it with: ${line}` : missing.homepage ? `${missing.message} ${missing.homepage}` : missing.message,
sticky: true,
actions,
});
+4 -3
Переглянути файл
@@ -8,6 +8,7 @@ import { CodePreview } from "./CodePreview";
import { guessSeparator, importEdits, parseDelimited } from "./model";
import { closeDialog, detailsKey, loadDetails, openConsoleWith, targetLabel, useDatabase } from "./store";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
function when(t: number): string {
const d = new Date(t);
@@ -60,7 +61,7 @@ export function HistoryDialog({ id, path }: { id: string; path: string | null })
<Dialog
open
onClose={closeDialog}
title={`${targetLabel(id)} Query History`}
title={tx`${targetLabel(id)} Query History`}
width={820}
footerStart={
<IconButton icon="trash" title="Clear History" disabled={!entries?.length} onClick={() => void remove(null)} />
@@ -192,12 +193,12 @@ export function ImportDialog({ id, schema, table }: { id: string; schema: string
<Dialog
open
onClose={closeDialog}
title={`Import Data into ${schema}.${table}`}
title={tx`Import Data into ${schema}.${table}`}
width={720}
buttons={[
{ label: "Cancel", role: "cancel" },
{
label: `Import ${edits.length} Rows`,
label: tx`Import ${edits.length} Rows`,
role: "default",
disabled: !edits.length || !mapped.length || busy,
onClick: () => void run(),
+4 -3
Переглянути файл
@@ -21,6 +21,7 @@ import {
} from "./resultView";
import { openDialog } from "./store";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
export type DataPanel = "record" | "value" | "aggregates";
@@ -30,7 +31,7 @@ export function exportMenu(data: ExportData, source: string, format?: ExportForm
{ label: "Export Data…", icon: "export", run: () => openDialog({ kind: "export", data, source, format }) },
{ kind: "separator" },
...EXPORT_FORMATS.map((f): MenuItem => ({
label: `Copy as ${f.label}`,
label: tx`Copy as ${f.label}`,
icon: "copy",
run: () => void navigator.clipboard?.writeText(exportData(f.id, data)),
})),
@@ -207,14 +208,14 @@ function FilterPopup(props: {
anchor={props.anchor}
placement="bottom-start"
onClose={props.onClose}
aria-label={`Local filter of ${props.column}`}
aria-label={tx`Local filter of ${props.column}`}
className={styles.filterPopup}
trapFocus
>
<Input
autoFocus
aria-label="Search values"
placeholder={`Values of ${props.column}`}
placeholder={tx`Values of ${props.column}`}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
+5 -3
Переглянути файл
@@ -6,6 +6,8 @@ import { sshLabel } from "./model";
import { SourceDialog } from "./SourceDialog";
import { closeDialog, useDatabase } from "./store";
import styles from "./Database.module.css";
import { isImeKey } from "@/platform/ime";
import { tx } from "@/platform/i18n";
const ObjectDialogs = lazyComponent(() => import("./ObjectDialogs").then((m) => m.ObjectDialogs));
const ExportDialog = lazyComponent(() => import("./ExportDialog").then((m) => m.ExportDialog));
@@ -27,7 +29,7 @@ function PasswordDialog({ source, resolve }: { source: DataSource; resolve: (ok:
<Dialog
open
onClose={() => done(false)}
title={`Connect to ${source.name}`}
title={tx`Connect to ${source.name}`}
width={400}
buttons={[
{ label: "Cancel", role: "cancel" },
@@ -81,7 +83,7 @@ function SshSecretDialog({
<Dialog
open
onClose={() => done(false)}
title={`SSH: ${sshLabel(source) || source.name}`}
title={tx`SSH: ${sshLabel(source) || source.name}`}
width={440}
buttons={[
{ label: "Cancel", role: "cancel" },
@@ -138,7 +140,7 @@ function ParamsDialog({
<div
className={styles.form}
onKeyDown={(e) => {
if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
if (e.key !== "Enter" || isImeKey(e.nativeEvent)) return;
e.preventDefault();
done(values);
}}
+2 -1
Переглянути файл
@@ -38,6 +38,7 @@ import {
} from "./store";
import { openTableData } from "./treeActions";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
/** Read-only SQL with the editor's syntax colours (IDEA's dialog Preview pane). */
function SqlPreview({ sql, label = "Preview" }: { sql: string; label?: string }) {
@@ -287,7 +288,7 @@ function ModifyTableDialog(props: ModifyProps) {
<Dialog
open
onClose={closeDialog}
title={`Modify Table ${table}`}
title={tx`Modify Table ${table}`}
width={440}
buttons={[{ label: "Cancel", role: "cancel" }]}
>
+19 -12
Переглянути файл
@@ -18,7 +18,7 @@ import {
type DbTxMode,
} from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { tx, useTx, useTxTemplate } from "@/platform/i18n";
import { sshLabel as configLabel } from "@/platform/remoteSettings";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { Button, Checkbox, Dialog, IconButton, Input, Select, Tabs, T, type SelectOption } from "@/ui";
@@ -36,6 +36,7 @@ import {
import { closeDialog, loadDatabases, loadSchemas, saveSource, useDatabase } from "./store";
import { VENDORS, sourceUrl, vendorById, vendorOf } from "./vendors";
import styles from "./Database.module.css";
import { isImeKey } from "@/platform/ime";
type Tab = "general" | "options" | "ssh" | "schemas" | "advanced";
@@ -236,7 +237,7 @@ function SecondsOption({
<Input
className={styles.secondsInput}
inputMode="numeric"
aria-label={`${label} (seconds)`}
aria-label={tx`${label} (seconds)`}
disabled={!on}
value={draft}
onChange={(e) => {
@@ -255,14 +256,18 @@ function SecondsOption({
/** Schemas tab: "All …" or a choice of names and `*` / `?` patterns. */
function NamePicker({
title,
allLabel,
addLabel,
all,
chosen,
known,
onAll,
onChosen,
}: {
title: string;
/** "All databases" */
allLabel: string;
/** Accessible name of the pattern input. */
addLabel: string;
all: boolean;
chosen: string[];
known: string[];
@@ -278,7 +283,7 @@ function NamePicker({
};
return (
<div className={styles.namePicker}>
<Checkbox checked={all} onChange={onAll} label={`All ${title}`} />
<Checkbox checked={all} onChange={onAll} label={allLabel} />
{!all && (
<div className={styles.nameList}>
{names.map((n) => (
@@ -294,10 +299,10 @@ function NamePicker({
className={styles.fill}
value={pattern}
placeholder="Name or pattern, e.g. app_*"
aria-label={`Add ${title} pattern`}
aria-label={addLabel}
onChange={(e) => setPattern(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
if (e.key === "Enter" && !isImeKey(e.nativeEvent)) {
e.preventDefault();
add();
}
@@ -420,7 +425,7 @@ export function SourceDialog({
setTest(null);
try {
const v = await call(commands.dbTest(effective(), password ?? null, sshOn ? (sshSecret ?? null) : null));
setTest({ ok: true, text: `Succeeded: ${v}` });
setTest({ ok: true, text: tx`Succeeded: ${v}` });
} catch (e) {
const text = e instanceof Error ? e.message : String(e);
const trust = e instanceof IpcError && connectRemedy(e.kind, e.message, { ssh: sshUsed }) === "trustHostKey";
@@ -686,7 +691,7 @@ export function SourceDialog({
const sshConfigOptions: SelectOption[] = [
{ value: "", label: "Custom (fields below)" },
...sshConfigs.map((c) => ({ value: c.id, label: c.name.trim() || configLabel(c) || c.id, detail: configLabel(c) })),
...(sshConfigId && !sshConfig ? [{ value: sshConfigId, label: `${sshConfigId} (missing)` }] : []),
...(sshConfigId && !sshConfig ? [{ value: sshConfigId, label: tx`${sshConfigId} (missing)` }] : []),
];
const sshTab = (
<>
@@ -863,7 +868,8 @@ export function SourceDialog({
<div className={styles.formWide}>
{postgres && (
<NamePicker
title="databases"
allLabel={tx("All databases")}
addLabel={tx("Add databases pattern")}
all={intro.allDatabases !== false}
chosen={intro.databases ?? []}
known={knownDbs}
@@ -872,7 +878,8 @@ export function SourceDialog({
/>
)}
<NamePicker
title={s.driver === "mysql" ? "schemas (databases)" : "schemas"}
allLabel={s.driver === "mysql" ? tx("All schemas (databases)") : tx("All schemas")}
addLabel={s.driver === "mysql" ? tx("Add schemas (databases) pattern") : tx("Add schemas pattern")}
all={intro.allSchemas !== false}
chosen={intro.schemas ?? []}
known={knownSchemas}
@@ -982,7 +989,7 @@ export function SourceDialog({
<Dialog
open
onClose={closeDialog}
title={initial ? `Data Source: ${initial.name}` : "New Data Source"}
title={initial ? tx`Data Source: ${initial.name}` : "New Data Source"}
width={640}
footerStart={
<Button onClick={() => void runTest()} disabled={busy || !valid}>
+2 -1
Переглянути файл
@@ -29,6 +29,7 @@ import {
import { openDialog, sourceById, withConnection } from "./store";
import type { TableRef } from "./tableInput";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
const PAGE_SIZES = ["100", "500", "1000", "5000"];
@@ -306,7 +307,7 @@ export function TableEditor({ input }: { input: EditorInput }) {
) : (
<div className={styles.fill}>
<DataView
aria-label={`${ref.table} data`}
aria-label={tx`${ref.table} data`}
columns={gridColumns}
rowCount={rowCount}
fixedRows={loaded}
+2 -1
Переглянути файл
@@ -58,6 +58,7 @@ import {
useDatabase,
} from "./store";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
/** `templates` feature's language-data key for finer template contexts. */
const TEMPLATE_CONTEXT_KEY = "openideaTemplateContexts";
@@ -287,7 +288,7 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
<Select
aria-label="Row limit"
value={String(maxRows)}
options={ROW_LIMITS.map((v) => ({ value: v, label: `${Number(v).toLocaleString("en")} rows` }))}
options={ROW_LIMITS.map((v) => ({ value: v, label: tx`${Number(v).toLocaleString("en")} rows` }))}
onChange={(v) => useDatabase.setState({ maxRows: Number(v) })}
/>
<span className={styles.toolbarSeparator} />
+4 -3
Переглянути файл
@@ -39,6 +39,7 @@ import type { ExportData, ExportFormat } from "./export";
import type { DbObjectRef } from "./objects";
import { formatted } from "./sql/format";
import { bindParams, findParams } from "./sql/params";
import { tx } from "@/platform/i18n";
export const DB_TOOL_WINDOW = "database";
export const RESULTS_VIEW = "dbResults";
@@ -282,7 +283,7 @@ export async function renameSource(id: string, name: string): Promise<void> {
export async function removeSource(source: DataSource): Promise<void> {
const ok = await confirm({
title: "Remove Data Source",
message: `Remove data source '${source.name}'? Its query console file stays on disk.`,
message: tx`Remove data source '${source.name}'? Its query console file stays on disk.`,
ok: "Remove",
});
if (!ok) return;
@@ -791,7 +792,7 @@ export async function executeConsole(
.filter((o) => o.result?.columns.length)
.map((outcome) => {
const n = nextTab++;
return { id: n, title: `Result ${n}`, path, sourceId: id, outcome };
return { id: n, title: tx`Result ${n}`, path, sourceId: id, outcome };
});
const failed = outcomes.some((o) => o.error);
useDatabase.setState((s) => ({
@@ -960,7 +961,7 @@ export async function finishTransaction(id: string, commit: boolean): Promise<bo
async function askEndTransaction(source: DataSource): Promise<boolean | null> {
const { button } = await ask({
title: "Switch to Auto-Commit",
body: `${source.name} has uncommitted changes. Commit or roll them back before switching to auto-commit?`,
body: tx`${source.name} has uncommitted changes. Commit or roll them back before switching to auto-commit?`,
buttons: [
{ id: "rollback", label: "Roll Back" },
{ id: "commit", label: "Commit", primary: true },
+8 -6
Переглянути файл
@@ -37,6 +37,7 @@ import {
removeSource,
} from "./store";
import { tableInput } from "./tableInput";
import { tx } from "@/platform/i18n";
/** Context key: the Database tree has focus. */
export const DB_VIEW_FOCUS = "databaseViewFocus";
@@ -57,11 +58,12 @@ function sourceOf(n: DbNode | null): DataSource | undefined {
return n ? sourceById(nodeTarget(n)) : undefined;
}
async function copy(text: string, what: string) {
/** Copies `text`; `failure` is the notification title when the clipboard refuses it. */
async function copy(text: string, failure: string) {
try {
await navigator.clipboard.writeText(text);
} catch {
notifications().notify({ level: "error", title: `Cannot copy the ${what}` });
notifications().notify({ level: "error", title: failure });
}
}
@@ -373,10 +375,10 @@ export function registerDatabaseTreeActions(): () => void {
const n = nodeFrom(arg);
if (!n) return;
const id = nodeTarget(n);
if (n.kind === "source") return void copy(n.source.name ?? "", "name");
if (n.kind === "database") return void copy(n.name, "name");
if (n.kind === "source") return void copy(n.source.name ?? "", tx("Cannot copy the name"));
if (n.kind === "database") return void copy(n.name, tx("Cannot copy the name"));
const o = objectOf(n);
if (id && o) void copy(reference(driverOf(id), o), "reference");
if (id && o) void copy(reference(driverOf(id), o), tx("Cannot copy the reference"));
},
}),
tree({
@@ -397,7 +399,7 @@ export function registerDatabaseTreeActions(): () => void {
run: async ({ arg }) => {
const n = nodeFrom(arg);
const ddl = n && (await ddlOf(n));
if (ddl) await copy(ddl.text, "DDL");
if (ddl) await copy(ddl.text, tx("Cannot copy the DDL"));
},
}),
tree({
+11
Переглянути файл
@@ -197,4 +197,15 @@ describe("run config → launch config", () => {
// lldb-dap takes `KEY=VALUE` strings.
expect(extra(dap)).toEqual({ env: ["K=v"] });
});
it("Godot: the editor's adapter plays the main scene or the chosen one of the project", () => {
const project = rc({ kind: "godot", command: "/Apps/Godot", args: ["--path", "."], cwd: "game", debugAdapter: "godot" });
const main = launchConfigFor(project, "/p")!;
expect(main).toMatchObject({ adapter: "godot", request: "launch", cwd: "/p/game", program: null });
expect(extra(main)).toEqual({ project: "/p/game", scene: "main" });
const scene = launchConfigFor({ ...project, args: ["--path", ".", "res://levels/one.tscn"] }, "/p")!;
expect(extra(scene)).toEqual({ project: "/p/game", scene: "res://levels/one.tscn" });
const relative = launchConfigFor({ ...project, cwd: null, args: ["--path", "game", "menu.tscn"] }, "/p")!;
expect(extra(relative)).toEqual({ project: "/p/game", scene: "res://menu.tscn" });
});
});
+8
Переглянути файл
@@ -137,6 +137,14 @@ describe("remote attach requests", () => {
});
});
it("Attach to Godot connects to the editor's debug adapter for the project", () => {
const l = remoteAttachConfig(configOf(remote.godotAttachType), "/p/game")!;
expect(l).toMatchObject({ adapter: "godot", request: "attach", cwd: "/p/game" });
expect(JSON.parse(l.extraJson!)).toEqual({ debugServer: "localhost:6006", project: "/p/game" });
const custom = remoteAttachConfig(configOf(remote.godotAttachType, { port: "6010" }), "/p")!;
expect(JSON.parse(custom.extraJson!).debugServer).toBe("localhost:6010");
});
it("Attach to Node.js attaches js-debug to the inspector with local/remote roots", () => {
const l = remoteAttachConfig(configOf(nodeRemoteType, { host: "web", remoteRoot: "/usr/src/app" }), "/p")!;
expect(l.adapter).toBe("js-debug");
+2 -1
Переглянути файл
@@ -43,6 +43,7 @@ import {
openSmartStepChooser,
useOverlays,
} from "./ui/popoverStore";
import { tx } from "@/platform/i18n";
function isRunConfig(arg: unknown): arg is RunConfig {
return !!arg && typeof arg === "object" && "command" in arg && "args" in arg && "debugAdapter" in arg;
@@ -159,7 +160,7 @@ async function listenForJvm(name: string, host: string, port: number): Promise<n
key,
sticky: true,
title: `'${name}': listening for the remote JVM on port ${port}`,
body: `Start the remote JVM with -agentlib:jdwp=transport=dt_socket,server=n,address=${host}:${port},suspend=y`,
body: tx`Start the remote JVM with -agentlib:jdwp=transport=dt_socket,server=n,address=${host}:${port},suspend=y`,
actions: [{ label: "Cancel", run: () => void commands.cancelRequest(request) }],
});
try {
+2 -1
Переглянути файл
@@ -8,6 +8,7 @@
import { call, commands, isTauri, type DebugEvent, type DebugSessionId } from "@/platform/ipc";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { isLive, useDebug, type DebugSession } from "./state";
import { tx } from "@/platform/i18n";
const KEY = "debug.hotswap";
/** `BUILD_COMPLETE` events of our own build are not worth a prompt. */
@@ -25,7 +26,7 @@ function plural(n: number, word: string) {
export function hotSwapMessage(changed: number, errors: string[]): { level: "info" | "error"; title: string; body?: string } {
if (errors.length) return { level: "error", title: "Hot Swap failed", body: errors.join("\n") };
if (changed === 0) return { level: "info", title: "Loaded classes are up to date. Nothing to reload." };
return { level: "info", title: `${plural(changed, "class")} reloaded` };
return { level: "info", title: tx`${plural(changed, "class")} reloaded` };
}
/** Compiles (unless `build` is false) and reloads changed classes into every Java debug session. */
+19
Переглянути файл
@@ -196,6 +196,24 @@ function delveLaunch(rc: RunConfig, cwd: string): LaunchConfig {
return out;
}
/** The scene a `godot --path <dir> [scene]` command line plays: `main`, or a `res://` path. */
export function godotScene(args: readonly string[]): string {
const scene = args.find((a, i) => args[i - 1] !== "--path" && /\.t?scn$/i.test(a));
if (!scene) return "main";
return scene.startsWith("res://") ? scene : `res://${scene.replace(/^\.\//, "")}`;
}
/**
* Godot: the adapter inside the editor plays the scene itself (with the editor's run
* settings), so the launch names the project (checked against the editor's) and the scene.
*/
function godotLaunch(rc: RunConfig, cwd: string): LaunchConfig {
const i = rc.args.indexOf("--path");
const dir = i >= 0 ? rc.args[i + 1] : undefined;
const project = dir ? join(cwd, dir) : cwd;
return { ...base(rc, "godot", project), extraJson: JSON.stringify({ project, scene: godotScene(rc.args) }) };
}
/** A native program under CodeLLDB or lldb-dap: program, args, cwd, env. */
function nativeLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
const out: LaunchConfig = { ...base(rc, adapter, cwd), program: resolveCommand(rc.command, cwd), args: [...rc.args] };
@@ -219,6 +237,7 @@ export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | nul
if (adapter === "debugpy") return debugpyLaunch(rc, cwd);
if (adapter === "js-debug") return jsDebugLaunch(rc, cwd);
if (adapter === "delve") return delveLaunch(rc, cwd);
if (adapter === "godot") return godotLaunch(rc, cwd);
// Adapters without a template (hidden in Edit Configurations): generic arguments.
return {
...base(rc, adapter, cwd),
+7 -7
Переглянути файл
@@ -5,11 +5,11 @@
import { IpcError } from "@/platform/ipc";
/** Adapters the tools installer can provide (adapter id → tool name). */
const INSTALLABLE: Record<string, { tool: string; label: string }> = {
debugpy: { tool: "debugpy", label: "debugpy" },
"js-debug": { tool: "js-debug", label: "the JavaScript debugger (vscode-js-debug)" },
delve: { tool: "delve", label: "Delve" },
codelldb: { tool: "codelldb", label: "CodeLLDB" },
const INSTALLABLE: Record<string, { tool: string; title: string }> = {
debugpy: { tool: "debugpy", title: "debugpy is not installed" },
"js-debug": { tool: "js-debug", title: "The JavaScript debugger (vscode-js-debug) is not installed" },
delve: { tool: "delve", title: "Delve is not installed" },
codelldb: { tool: "codelldb", title: "CodeLLDB is not installed" },
};
export interface MissingAdapter {
@@ -25,14 +25,14 @@ export function missingAdapter(adapter: string, e: unknown): MissingAdapter | nu
if (adapter === "debugpy" && e.message.startsWith("debugpy is not installed")) {
return {
tool: entry.tool,
title: "debugpy is not installed",
title: entry.title,
body: `${e.message}. OpenIDEA can install a private copy that every Python interpreter uses for debugging.`,
};
}
if (!e.message.startsWith(`debug adapter \`${adapter}\` not found`)) return null;
return {
tool: entry.tool,
title: `${entry.label[0]!.toUpperCase()}${entry.label.slice(1)} is not installed`,
title: entry.title,
body: e.message,
};
}
+28
Переглянути файл
@@ -12,6 +12,7 @@ export const REMOTE_JVM = "remote-jvm";
export const GO_REMOTE = "go-remote";
export const PYTHON_REMOTE = "python-remote";
export const NODE_REMOTE = "node-remote";
export const GODOT_ATTACH = "godot-attach";
type Params = Readonly<Record<string, string>>;
@@ -220,11 +221,36 @@ export const nodeRemoteType: RunConfigTypeContribution = {
commandLine: () => null,
};
// ---------------------------------------------------------------------------
// Attach to Godot (the editor's debug adapter, Editor Settings › Network › Debug Adapter)
export const godotAttachType: RunConfigTypeContribution = {
id: GODOT_ATTACH,
label: "Attach to Godot",
icon: "file-gdscript",
description: "Debug the game the Godot editor is running, through the editor's debug adapter",
defaults: { host: "localhost", port: "6006" },
debugOnly: true,
debugAdapter: "godot",
fields: [
hostField,
portField,
{
key: "hint",
label: "Before attaching",
control: "output",
value: () => "Open the project in the Godot editor and run a scene (F5 / F6)",
},
],
commandLine: () => null,
};
export const REMOTE_TYPES: readonly RunConfigTypeContribution[] = [
remoteJvmType,
goRemoteType,
pythonRemoteType,
nodeRemoteType,
godotAttachType,
];
export const isRemoteConfig = (c: Pick<RunConfig, "kind">) => REMOTE_TYPES.some((t) => t.id === c.kind);
@@ -291,6 +317,8 @@ export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig |
justMyCode: skip,
...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}),
});
case GODOT_ATTACH:
return attachBase(rc, "godot", root, { debugServer: serverAddress(host, portOf(p, 6006)), project: root });
case NODE_REMOTE:
return attachBase(rc, "js-debug", root, {
type: "pwa-node",
+2 -1
Переглянути файл
@@ -40,6 +40,7 @@ import {
} from "../state";
import { VariablesView, type VarRoot } from "./VariablesView";
import styles from "./DebugToolWindow.module.css";
import { tx } from "@/platform/i18n";
function useActionEnabled(id: string): boolean {
const ctx = useContextKeys();
@@ -114,7 +115,7 @@ export function DebugTabs() {
if (!list.length) return null;
const items: TabItem[] = list.map((s) => ({
id: String(s.id),
label: s.parent != null ? `${s.name} (child)` : s.name,
label: s.parent != null ? tx`${s.name} (child)` : s.name,
text: s.name,
icon: "debug",
italic: s.status === "terminated",
+5 -3
Переглянути файл
@@ -33,6 +33,8 @@ import { closeBreakpointPopover, useOverlays } from "./popoverStore";
import { VariablesView, type VarRoot } from "./VariablesView";
import { pushHistory, readHistory } from "./expressionHistory";
import styles from "./Overlays.module.css";
import { isImeKey } from "@/platform/ime";
import { tx } from "@/platform/i18n";
export function DebuggerOverlays() {
return (
@@ -446,7 +448,7 @@ function BreakpointsDialog() {
<Checkbox
checked={r.bp.enabled}
onChange={(enabled) => updateSpecial(r.bp.id, { enabled })}
aria-label={`Enable ${specialTitle(r.bp)}`}
aria-label={tx`Enable ${specialTitle(r.bp)}`}
/>
<Icon name={r.bp.kind === "method" ? "breakpoint-method" : "breakpoint-field"} size={14} />
<span className={styles.bpName}>{specialTitle(r.bp)}</span>
@@ -466,7 +468,7 @@ function BreakpointsDialog() {
<Checkbox
checked={r.bp.enabled}
onChange={(enabled) => updateBreakpoint(r.path, r.bp.id, { enabled })}
aria-label={`Enable breakpoint at ${basename(r.path)}:${r.bp.line + 1}`}
aria-label={tx`Enable breakpoint at ${basename(r.path)}:${r.bp.line + 1}`}
/>
<span
className={styles.bpDot}
@@ -710,7 +712,7 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
});
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return;
if (isImeKey(e.nativeEvent)) return;
const el = e.currentTarget;
if (e.key === "Enter" && e.shiftKey && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
+4 -3
Переглянути файл
@@ -35,6 +35,7 @@ import {
import { addComment, checkoutPullRequest, openCommentLocation, openFileDiff, openUrl, submitReview } from "./ops";
import { cancelBusy, loadDetails, refreshAll, select, setQuery, useGithub, type DetailTab } from "./store";
import styles from "./Github.module.css";
import { tx } from "@/platform/i18n";
const STATES: Array<{ value: PrStateFilter; label: string }> = [
{ value: "open", label: "Open" },
@@ -515,9 +516,9 @@ function Details() {
aria-label="Pull request"
items={[
{ id: "overview", label: "Overview" },
{ id: "files", label: `Files ${pr.files.length}` },
{ id: "commits", label: `Commits ${pr.commits.length}` },
{ id: "conversation", label: `Conversation ${pr.timeline.length + comments.length}` },
{ id: "files", label: tx`Files ${pr.files.length}` },
{ id: "commits", label: tx`Commits ${pr.commits.length}` },
{ id: "conversation", label: tx`Conversation ${pr.timeline.length + comments.length}` },
]}
/>
{tab === "overview" && <Overview pr={pr} />}
+3 -2
Переглянути файл
@@ -5,6 +5,7 @@ import type { ReviewEvent } from "@/platform/ipc";
import { call, commands } from "@/platform/ipc";
import { Checkbox, Dialog, Input, Select, T } from "@/ui";
import styles from "./Github.module.css";
import { tx } from "@/platform/i18n";
interface Entry {
id: number;
@@ -134,7 +135,7 @@ function CreateDialog(props: {
/>
<div className={styles.formRow}>
<Checkbox checked={draft} onChange={setDraft} label="Draft" />
<Checkbox checked={push} onChange={setPush} label={`Push '${props.head}' first`} />
<Checkbox checked={push} onChange={setPush} label={tx`Push '${props.head}' first`} />
</div>
</div>
</Dialog>
@@ -165,7 +166,7 @@ function ReviewDialog(props: {
return (
<Dialog
open
title={`Review #${props.number}`}
title={tx`Review #${props.number}`}
onClose={() => props.onDone(null)}
width={520}
buttons={[
+2 -1
Переглянути файл
@@ -10,6 +10,7 @@ import { EmptyState, IconButton, SplitGroup, SplitHandle, SplitPanel, Spinner, T
import { bodyKind, formatMs, formatSize, prettyBody } from "./format";
import { cancelRunning, clearHistory, refreshHistory, selectEntry, useHttpClient } from "./store";
import styles from "./HttpClient.module.css";
import { tx } from "@/platform/i18n";
function statusTone(status: number | null | undefined): string {
if (status == null) return styles.statusError ?? "";
@@ -73,7 +74,7 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
onSelect={(id) => setTab(id as Tab)}
items={[
{ id: "body", label: "Response" },
{ id: "headers", label: `Headers${r ? ` (${r.headers.length})` : ""}` },
{ id: "headers", label: r ? tx`Headers (${r.headers.length})` : "Headers" },
{ id: "request", label: "Request" },
]}
trailing={
+2
Переглянути файл
@@ -36,6 +36,7 @@ import { activate as github } from "./github";
import { activate as httpClient } from "./http-client";
import { activate as database } from "./database";
import { activate as deployment } from "./deployment";
import { activate as agents } from "./agents";
import { activate as vim, activateSettingsPages as vimPages } from "./vim";
import { activate as ssh } from "./ssh";
import { activate as remoteDev } from "./remote-dev";
@@ -76,6 +77,7 @@ const features: Array<[string, Activate]> = [
["http-client", httpClient],
["database", database],
["deployment", deployment],
["agents", agents],
["vim", vim],
["ssh", ssh],
["remote-dev", remoteDev],
+2 -2
Переглянути файл
@@ -2,7 +2,7 @@
import { useState } from "react";
import { create } from "zustand";
import { executeAction, getAction } from "@/platform/actions";
import { t } from "@/platform/i18n";
import { t, tx } from "@/platform/i18n";
import { BarButton, Button, Dialog, Icon, List, T } from "@/ui";
import { confirm } from "@/workbench/messages";
import { promptInput } from "@/workbench/inputDialogs";
@@ -60,7 +60,7 @@ function EditMacrosDialog({ onClose }: { onClose: () => void }) {
};
const remove = async () => {
if (!macro) return;
if (await confirm({ title: "Delete Macro", message: `Delete the macro '${macro.name}'?`, ok: "Delete", icon: "warning" }))
if (await confirm({ title: "Delete Macro", message: tx`Delete the macro '${macro.name}'?`, ok: "Delete", icon: "warning" }))
deleteMacro(macro.name);
};
+2 -1
Переглянути файл
@@ -21,6 +21,7 @@ import {
useMacros,
type MacroStep,
} from "./model";
import { isImeKey } from "@/platform/ime";
/** Records typing and editor keys of every text editor while a macro records. */
const recorder = [
@@ -31,7 +32,7 @@ const recorder = [
EditorView.domEventHandlers({
keydown(e) {
// Keys the IDE keymap ran as actions were recorded as those actions.
if (e.defaultPrevented || e.isComposing || !useMacros.getState().recording) return false;
if (e.defaultPrevented || isImeKey(e) || !useMacros.getState().recording) return false;
const key = editorKeyName(e);
if (key) recordStep({ kind: "key", key });
return false;
+3 -2
Переглянути файл
@@ -14,6 +14,7 @@ import { indicatorLabel, indicatorTooltip, memorySections, summarizeMemory } fro
import { startPolling } from "./poll";
import { METRICS_INTERVAL_MS, refreshMetrics, usePerf } from "./store";
import styles from "./perf.module.css";
import { tx } from "@/platform/i18n";
/** Status bar widget id (hidden by default, toggled from the status bar context menu). */
export const MEMORY_WIDGET = { id: "perf.memory", title: "Memory Indicator", defaultVisible: false } as const;
@@ -41,7 +42,7 @@ export function MemoryStatus() {
ref={ref}
className={styles.indicator}
aria-expanded={open}
aria-label={`Memory: ${tooltip}`}
aria-label={tx`Memory: ${tooltip}`}
tooltip={tooltip}
onClick={() => usePerf.setState({ popupOpen: !open })}
>
@@ -101,7 +102,7 @@ export function MemoryDetails({ metrics: m }: { metrics: AppMetrics }) {
label={r.label}
detail={r.detail}
value={r.value}
title={r.processes > 1 ? `${r.processes} processes` : undefined}
title={r.processes > 1 ? tx`${r.processes} processes` : undefined}
indented
/>
))}
+6 -2
Переглянути файл
@@ -6,6 +6,7 @@ import { useProject } from "@/platform/project";
import { formatSize } from "./format";
import { MEMORY_WIDGET } from "./MemoryStatus";
import { clearCaches, refreshMetrics, usePerf } from "./store";
import { tx } from "@/platform/i18n";
function memoryIndicatorShown(): boolean {
const overrides = useProject.getState().settings?.appearance?.statusBarWidgets;
@@ -30,12 +31,15 @@ export function perfActions(): Action[] {
}
const memory =
r.rssBeforeBytes != null && r.rssAfterBytes != null
? ` Memory ${formatSize(r.rssBeforeBytes)} → ${formatSize(r.rssAfterBytes)}.`
? tx` Memory ${formatSize(r.rssBeforeBytes)} → ${formatSize(r.rssAfterBytes)}.`
: "";
notifications().notify({
level: "info",
title: "Caches cleared",
body: `${r.syntaxTrees} syntax tree${r.syntaxTrees === 1 ? "" : "s"} dropped, index compacted.${memory}`,
body:
r.syntaxTrees === 1
? tx`${r.syntaxTrees} syntax tree dropped, index compacted.${memory}`
: tx`${r.syntaxTrees} syntax trees dropped, index compacted.${memory}`,
key: "perf.clearCaches",
});
} catch (e) {
+5 -4
Переглянути файл
@@ -39,6 +39,7 @@ import {
usePlugins,
} from "./store";
import styles from "./PluginsPage.module.css";
import { tx } from "@/platform/i18n";
const STATUS: Record<PluginStatus, { label: string; tone: "success" | "accent" | "neutral" | "danger" }> = {
active: { label: "Running", tone: "success" },
@@ -137,7 +138,7 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele
checked={plugin.enabled}
disabled={busy}
onChange={(on) => void setPluginEnabled(plugin.id, on)}
aria-label={`${plugin.enabled ? "Disable" : "Enable"} ${plugin.name}`}
aria-label={plugin.enabled ? tx`Disable ${plugin.name}` : tx`Enable ${plugin.name}`}
/>
</li>
);
@@ -308,7 +309,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
icon="run"
disabled={!running}
onClick={() => void runPluginCommand(plugin.id, c.id)}
aria-label={`Run ${c.title}`}
aria-label={tx`Run ${c.title}`}
>
<T>Run</T>
</Button>
@@ -338,7 +339,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
variant="ghost"
disabled={!live}
onClick={() => void selectPluginTheme(plugin.id, t.id)}
aria-label={`Apply theme ${t.name}`}
aria-label={tx`Apply theme ${t.name}`}
>
<T>Apply</T>
</Button>
@@ -407,7 +408,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
variant="primary"
disabled={busy}
onClick={() => void uninstallPlugin(plugin.id)}
aria-label={`Confirm uninstall ${plugin.name}`}
aria-label={tx`Confirm uninstall ${plugin.name}`}
>
<T>Uninstall</T>
</Button>
Loaded 100 of 381 files, more files were not shown because too many files have changed in this diff. Show more