дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
Editor: quick fixes, completion, navigation while typing, and split-pane drag (#109)
* feat(editor): Drag tabs and files between split panes
HTML5 drag and drop never reaches the webview while native file drop is
enabled, so tab, project-file and changelist drags did nothing. They now
run on pointer events:
- Drag an editor tab onto another pane, or onto a pane's edge to split it.
- Drag files from the Project view into an editor pane (center opens them
there, an edge opens them in a new split).
- Drag files between changelists in the Commit view.
- "Drag-and-drop with Alt pressed only" applies to tabs and stripe buttons.
* fix(lsp): Deliver server settings and start servers on a supported JDK
- workspace/configuration answers a server's own section (basedpyright)
from its settings block; basedpyright runs in "standard" mode and keeps
auto search paths on, so `src/` layouts resolve from `tests/`.
- jdtls: formatting follows the editor indent, calls complete without
guessed arguments, and JDK internals stay out of completion.
- `max-java` in a language server config picks an installed JDK no newer
than that (kotlin-language-server fails on JDK 25).
- Server initialization may take up to 5 minutes (first Gradle import).
- `lsp_resend_diagnostics` re-emits stored diagnostics after a reload.
- Spelling only checks declared names (not called methods like
`substring`) and accepts plurals of known software terms.
* feat(editor): Quick fixes, completion and navigation while typing
Quick fixes
- Errors get a red quick-fix bulb; Apply Quick Fix (⌥⇧↵) fixes the most
severe problem at the caret, and the error hover offers the fix directly.
- Code generated by a code action is re-indented to the file's style.
- Python: Implement abstract methods (⌃I or the bulb) from the bases.
- Duplicate code actions are listed once.
Completion
- Overloads sharing a name, and a name importable from several modules,
are no longer merged into one entry.
- Auto-import entries show the module they import from.
- Call snippets without a stop (`new ArrayList<>()`) leave the caret inside
the parentheses with parameter info, or past them when nothing takes
arguments; parameter info also opens after server call snippets.
- The selected item is resolved ahead of acceptance, so its auto-import
is applied (rust-analyzer).
Navigation and diagnostics
- Language server requests wait for pending edits, so Go to Declaration
right after typing answers for the current text; an empty answer right
after an edit is retried while the server catches up.
- Diagnostics come back after a window reload; error underlines stay
visible under typo / unused highlighting.
- Implementation gutter markers no longer refresh in a loop while the
server reports progress.
- A missing alternative server no longer shows as a server error.
* chore(lint): Skip agent worktrees under .claude
* fix(editor): Skip cancelled LSP requests and limit generated-code reformat
- Requests cancelled while waiting for pending edits are not sent.
- Code generated by an action is reformatted per changed hunk, so an added
import doesn't reformat the code between it and the generated lines.
- A long busy spell caused by a settle refresh re-runs it only once.
- Files dropped on a file row of another changelist move to that changelist.
- Python Implement methods: a nested class without bases ends the lookup,
class bodies continue through unindented string lines, and
`@abstractproperty` stubs become `@property`.
- Resend diagnostics goes through the host ops, and custom servers get
`max_java`.
* fix(editor): Rank the exact type first in Java constructor completion
jdtls appends the call or body to filterText (`String(${0})`); matching
on the label instead lets typing `String` rank `String` above
`StringBuilder` and the other types sharing its sort text.
* fix(editor): List identical completion items once
kotlin-language-server repeats a class once per classpath entry declaring
it, so `String()` and `String` were listed three times each.
* fix(editor): Keep the internal cancellation message out of the UI text catalog
Цей коміт міститься в:
1 джерело
48ac2094fc
коміт
357b728f1d
71 files changed
+1630
-197
No files matched your search
@@ -5,7 +5,7 @@ import { getAction, executeAction } from "@/platform/actions";
|
||||
import { BarButton, Button, IconButton, Popup, T } from "@/ui";
|
||||
import { downloadJdkActions } from "@/platform/jdk";
|
||||
import { relativePath, useProject } from "@/platform/project";
|
||||
import { STATUS_LABEL, restartServer, serversSummary, useServers } from "./servers";
|
||||
import { STATUS_LABEL, restartServer, servedByOther, serversSummary, useServers } from "./servers";
|
||||
import styles from "./ServerStatus.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -49,7 +49,10 @@ export function ServerStatus() {
|
||||
return (
|
||||
<div key={s.id} className={styles.server}>
|
||||
<div className={styles.line}>
|
||||
<span className={clsx(styles.dot, styles[toneOf(s.status)])} aria-hidden />
|
||||
<span
|
||||
className={clsx(styles.dot, styles[s.status === "missing" && servedByOther(s, servers) ? "idle" : toneOf(s.status)])}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={styles.name}>{s.name}</span>
|
||||
<span className={styles.status}>{STATUS_LABEL[s.status]}</span>
|
||||
<span className={styles.spacer} />
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
|
||||
const { emitLocalIdeEvent } = await import("@/platform/ipc");
|
||||
const { useProject } = await import("@/platform/project");
|
||||
const { installServerEvents, useServers } = await import("./servers");
|
||||
const { installServerEvents, serversSummary, useServers } = await import("./servers");
|
||||
|
||||
describe("server status", () => {
|
||||
let dispose = () => {};
|
||||
@@ -40,3 +40,18 @@ describe("server status", () => {
|
||||
expect(useServers.getState().servers.get(id)).toMatchObject({ status: "missing", message: "install jdtls" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("serversSummary", () => {
|
||||
const entry = (id: number, name: string, status: ServerInfo["status"]) => ({
|
||||
id: id as ServerId,
|
||||
name,
|
||||
status,
|
||||
message: null,
|
||||
languages: ["typescript"],
|
||||
root: "/p",
|
||||
});
|
||||
it("doesn't flag a missing server whose languages another one serves", () => {
|
||||
expect(serversSummary([entry(1, "vtsls", "missing"), entry(2, "typescript-language-server", "ready")])?.tone).toBe("ok");
|
||||
expect(serversSummary([entry(1, "vtsls", "missing")])?.tone).toBe("error");
|
||||
});
|
||||
});
|
||||
@@ -129,10 +129,23 @@ export const STATUS_LABEL: Record<ServerStatus, string> = {
|
||||
misconfigured: "Not started",
|
||||
};
|
||||
|
||||
/**
|
||||
* A missing server whose languages another installed one serves (vtsls missing,
|
||||
* typescript-language-server running as its fallback) is not a problem.
|
||||
*/
|
||||
export function servedByOther(server: ServerEntry, servers: readonly ServerEntry[]): boolean {
|
||||
return (
|
||||
server.languages.length > 0 &&
|
||||
server.languages.every((l) => servers.some((o) => o !== server && o.status !== "missing" && o.languages.includes(l)))
|
||||
);
|
||||
}
|
||||
|
||||
/** One-line summary for the status bar. */
|
||||
export function serversSummary(servers: readonly ServerEntry[]): { text: string; tone: "ok" | "busy" | "error" | "idle" } | null {
|
||||
if (!servers.length) return null;
|
||||
const bad = servers.filter((s) => s.status === "crashed" || s.status === "missing" || s.status === "misconfigured");
|
||||
const bad = servers.filter(
|
||||
(s) => s.status === "crashed" || s.status === "misconfigured" || (s.status === "missing" && !servedByOther(s, servers)),
|
||||
);
|
||||
const busy = servers.filter((s) => s.status === "starting" || s.status === "busy");
|
||||
const tone = bad.length ? "error" : busy.length ? "busy" : servers.some((s) => s.status === "ready") ? "ok" : "idle";
|
||||
const names = [...new Set(servers.map((s) => s.name))];
|
||||
|
||||
@@ -28,14 +28,17 @@ import { useEffectiveRoots } from "@/platform/projectRoots";
|
||||
import { macStroke, strokeOf } from "@/platform/keymap";
|
||||
import { prepareMenuGroups, useFileDecoration } from "@/workbench/contributions";
|
||||
import {
|
||||
FILES_DRAG,
|
||||
HighlightedText,
|
||||
Icon,
|
||||
Popup,
|
||||
dragSession,
|
||||
Tree,
|
||||
acquirePopup,
|
||||
fileIconName,
|
||||
folderIconName,
|
||||
showContextMenu,
|
||||
type DragSession,
|
||||
type TreeHandle,
|
||||
type TreeRowState,
|
||||
} from "@/ui";
|
||||
@@ -443,6 +446,8 @@ export function ProjectView() {
|
||||
startY = e.clientY;
|
||||
let active = false;
|
||||
let paths: string[] = [];
|
||||
// Files (not folders) can also be dropped on an editor to open them there.
|
||||
let files: DragSession | null = null;
|
||||
const move = (ev: PointerEvent) => {
|
||||
if (!active) {
|
||||
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
|
||||
@@ -454,10 +459,14 @@ export function ProjectView() {
|
||||
const nodes = keys.map((k) => m.byKey.get(k)).filter((n): n is ProjectNode => !!n && !n.root && !n.placeholder);
|
||||
paths = nodes.map((n) => n.path);
|
||||
if (!paths.length) return cleanup();
|
||||
const filePaths = nodes.filter((n) => !n.dir).map((n) => n.path);
|
||||
if (filePaths.length) files = dragSession({ type: FILES_DRAG, payload: filePaths.join("\n") });
|
||||
}
|
||||
const target = folderAt(containerRef.current, ev.clientX, ev.clientY);
|
||||
const valid = target && !paths.some((p) => target.path === p || isInside(target.path, p));
|
||||
setDropTarget(valid ? target.key : null);
|
||||
if (target) files?.cancel();
|
||||
else files?.move(ev.clientX, ev.clientY);
|
||||
setDrag({
|
||||
paths,
|
||||
label: paths.length === 1 ? nameOf(paths[0]!) : tx`${paths.length} items`,
|
||||
@@ -469,8 +478,11 @@ export function ProjectView() {
|
||||
const up = (ev: PointerEvent) => {
|
||||
const target = active ? folderAt(containerRef.current, ev.clientX, ev.clientY) : null;
|
||||
const valid = target && !paths.some((p) => target.path === p || isInside(target.path, p));
|
||||
const session = files;
|
||||
files = null;
|
||||
cleanup();
|
||||
if (active && valid) void dropPaths(paths, target.path, ev.altKey);
|
||||
else if (active && !target) session?.drop(ev.clientX, ev.clientY);
|
||||
};
|
||||
const key = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Escape" && active) {
|
||||
@@ -483,6 +495,7 @@ export function ProjectView() {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("keydown", key, true);
|
||||
files?.cancel();
|
||||
setDropTarget(null);
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { executeAction, type Action } from "@/platform/actions";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { setDragRequiresAlt } from "@/ui";
|
||||
import { ZOOM_LIMITS } from "./model/behaviorSchema";
|
||||
import { DEFAULT_SETTINGS, validateSettings, type ValidSettings } from "./model/schema";
|
||||
|
||||
@@ -126,16 +127,9 @@ export function zoomActions(): Action[] {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drag-and-drop with Alt pressed only (tool window buttons and editor tabs)
|
||||
|
||||
const DRAG_SOURCES = "[data-stripe-button], [role='tab']";
|
||||
|
||||
export function installAltDragGuard(enabled: () => boolean): () => void {
|
||||
const onDragStart = (e: DragEvent) => {
|
||||
if (!enabled() || e.altKey) return;
|
||||
const el = e.target instanceof Element ? e.target : null;
|
||||
if (el?.closest(DRAG_SOURCES)) e.preventDefault();
|
||||
};
|
||||
document.addEventListener("dragstart", onDragStart, true);
|
||||
return () => document.removeEventListener("dragstart", onDragStart, true);
|
||||
setDragRequiresAlt(enabled);
|
||||
return () => setDragRequiresAlt(() => false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Commit tool window: change list with inclusion checkboxes, message, stashes. */
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent } from "react";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import clsx from "clsx";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
Tabs,
|
||||
Tree,
|
||||
fileIconName,
|
||||
registerDropTarget,
|
||||
showContextMenu,
|
||||
startPointerDrag,
|
||||
type MenuItem,
|
||||
type TreeHandle,
|
||||
T,
|
||||
@@ -424,35 +426,38 @@ function ChangesPanel() {
|
||||
{n.label}
|
||||
</span>
|
||||
);
|
||||
// Pointer-driven (HTML5 drag and drop never reaches the Tauri webview):
|
||||
// files and folders drag; any row takes them into its changelist.
|
||||
const list = n.changelist;
|
||||
const dnd =
|
||||
n.changelist !== undefined
|
||||
? {
|
||||
draggable: n.type !== "group",
|
||||
onDragStart: (e: DragEvent<HTMLSpanElement>) => {
|
||||
const paths =
|
||||
selected.has(n.key) && selected.size > 1
|
||||
? filesOf(selectedNodes()).map((f) => f.path)
|
||||
: n.files.map((f) => f.path);
|
||||
e.dataTransfer.setData(CHANGES_MIME, paths.map(stripSlash).join("\n"));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
},
|
||||
onDragOver: (e: DragEvent<HTMLSpanElement>) => {
|
||||
if (![...e.dataTransfer.types].includes(CHANGES_MIME)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dropTarget !== n.changelist) setDropTarget(n.changelist ?? null);
|
||||
},
|
||||
onDragLeave: () => setDropTarget(null),
|
||||
onDrop: (e: DragEvent<HTMLSpanElement>) => {
|
||||
const data = e.dataTransfer.getData(CHANGES_MIME);
|
||||
setDropTarget(null);
|
||||
if (!data || !n.changelist) return;
|
||||
e.preventDefault();
|
||||
moveToChangelist(data.split("\n"), n.changelist);
|
||||
},
|
||||
onDragEnd: () => setDropTarget(null),
|
||||
}
|
||||
: {};
|
||||
list === undefined
|
||||
? {}
|
||||
: {
|
||||
ref: (el: HTMLSpanElement | null) =>
|
||||
el
|
||||
? registerDropTarget(el, {
|
||||
accepts: (type) => type === CHANGES_MIME,
|
||||
over: () => setDropTarget(list),
|
||||
leave: () => setDropTarget(null),
|
||||
drop: (data) => {
|
||||
setDropTarget(null);
|
||||
moveToChangelist(data.payload.split("\n"), list);
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
...(n.type !== "group" && {
|
||||
onPointerDown: (e: PointerEvent<HTMLSpanElement>) => {
|
||||
const paths =
|
||||
selected.has(n.key) && selected.size > 1
|
||||
? filesOf(selectedNodes()).map((f) => f.path)
|
||||
: n.files.map((f) => f.path);
|
||||
startPointerDrag(e, e.currentTarget, {
|
||||
type: CHANGES_MIME,
|
||||
payload: paths.map(stripSlash).join("\n"),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
|
||||
Reference in new issue
Заблокувати користувача