дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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
Цей коміт міститься в:
коміт
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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 ?? [],
|
||||
];
|
||||
}
|
||||
@@ -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 ?? []),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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" }]}
|
||||
>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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
Reference in new issue
Заблокувати користувача