дзеркало
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
+39
-40
@@ -11,7 +11,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type DragEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
@@ -21,6 +20,7 @@ import { Icon, type IconName } from "./icons";
|
||||
import { IconButton } from "./Button";
|
||||
import { Menu, type MenuItem } from "./Menu";
|
||||
import { tooltipAttrs } from "./Tooltip";
|
||||
import { registerDropTarget, startPointerDrag } from "./pointerDrag";
|
||||
import { T } from "./T";
|
||||
import styles from "./Tabs.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -148,37 +148,36 @@ export function Tabs({
|
||||
[register],
|
||||
);
|
||||
|
||||
const accepts = (e: DragEvent) => !!dnd && [...e.dataTransfer.types].includes(dnd.type);
|
||||
const indexAt = (e: DragEvent) =>
|
||||
dropIndex(
|
||||
vertical ? e.clientY : e.clientX,
|
||||
items.map((it) => {
|
||||
const r = tabRefs.current.get(it.id)?.parentElement?.getBoundingClientRect();
|
||||
return vertical ? { left: r?.top ?? 0, width: r?.height ?? 0 } : { left: r?.left ?? 0, width: r?.width ?? 0 };
|
||||
}),
|
||||
);
|
||||
const dropHandlers = dnd
|
||||
? {
|
||||
onDragOver: (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!accepts(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const i = indexAt(e);
|
||||
if (i !== drop) setDrop(i);
|
||||
},
|
||||
onDragLeave: (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDrop(null);
|
||||
},
|
||||
onDrop: (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!accepts(e)) return;
|
||||
e.preventDefault();
|
||||
const payload = e.dataTransfer.getData(dnd.type);
|
||||
const i = indexAt(e);
|
||||
setDrop(null);
|
||||
if (payload) dnd.onDrop(payload, i);
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
// Re-read on every drag event: items and handlers change while the strip stays registered.
|
||||
const dropState = useRef({ items, dnd, vertical });
|
||||
useLayoutEffect(() => {
|
||||
dropState.current = { items, dnd, vertical };
|
||||
});
|
||||
const dndType = dnd?.type;
|
||||
useEffect(() => {
|
||||
const el = stripRef.current;
|
||||
if (!el || !dndType) return;
|
||||
const indexAt = (x: number, y: number) => {
|
||||
const { items, vertical } = dropState.current;
|
||||
return dropIndex(
|
||||
vertical ? y : x,
|
||||
items.map((it) => {
|
||||
const r = tabRefs.current.get(it.id)?.parentElement?.getBoundingClientRect();
|
||||
return vertical ? { left: r?.top ?? 0, width: r?.height ?? 0 } : { left: r?.left ?? 0, width: r?.width ?? 0 };
|
||||
}),
|
||||
);
|
||||
};
|
||||
return registerDropTarget(el, {
|
||||
accepts: (type) => type === dropState.current.dnd?.type,
|
||||
over: (_, x, y) => setDrop(indexAt(x, y)),
|
||||
leave: () => setDrop(null),
|
||||
drop: (data, x, y) => {
|
||||
setDrop(null);
|
||||
dropState.current.dnd?.onDrop(data.payload, indexAt(x, y));
|
||||
},
|
||||
});
|
||||
}, [dndType]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -249,7 +248,7 @@ export function Tabs({
|
||||
closePosition === "left" && styles.closeLeft,
|
||||
className,
|
||||
)}
|
||||
{...dropHandlers}
|
||||
ref={stripRef}
|
||||
>
|
||||
{vertical && trailing && <div className={styles.trailing}>{trailing}</div>}
|
||||
<div
|
||||
@@ -279,17 +278,17 @@ export function Tabs({
|
||||
item.tint && styles.tinted,
|
||||
)}
|
||||
style={item.tint ? ({ "--tab-tint": item.tint } as CSSProperties) : undefined}
|
||||
draggable={!!dnd}
|
||||
onDragStart={
|
||||
onPointerDown={
|
||||
dnd
|
||||
? (e) => {
|
||||
e.dataTransfer.setData(dnd.type, dnd.payload(item.id));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
setDragging(item.id);
|
||||
if ((e.target as Element).closest("[data-tab-close]")) return;
|
||||
startPointerDrag(e, e.currentTarget, { type: dnd.type, payload: dnd.payload(item.id) }, {
|
||||
onStart: () => setDragging(item.id),
|
||||
onEnd: () => setDragging(null),
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragEnd={dnd ? () => (setDragging(null), setDrop(null)) : undefined}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1 && closable) {
|
||||
e.preventDefault();
|
||||
@@ -324,7 +323,7 @@ export function Tabs({
|
||||
{item.modified && !closable && <span className={styles.modified} aria-label="Modified" />}
|
||||
</button>
|
||||
{closable && (
|
||||
<span className={styles.closeSlot}>
|
||||
<span className={styles.closeSlot} data-tab-close>
|
||||
{item.modified && <span className={styles.modified} aria-label="Modified" />}
|
||||
<IconButton
|
||||
icon="close"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="5.70142" y="12" width="4.6" height="1" fill="#6C707E"/>
|
||||
<path d="M6 14H10C10 14.5523 9.55228 15 9 15H7C6.44772 15 6 14.5523 6 14Z" fill="#6C707E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8704 9.14748C12.0417 8.27221 12.8 6.87465 12.8 5.3C12.8 2.64903 10.6509 0.5 7.99995 0.5C5.34898 0.5 3.19995 2.64903 3.19995 5.3C3.19995 6.87464 3.95817 8.27218 5.12943 9.14746L5.49994 11H10.4999L10.8704 9.14748Z" fill="#E55765"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 670 B |
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="5.70142" y="12" width="4.6" height="1" fill="#CED0D6"/>
|
||||
<path d="M6 14H10C10 14.5523 9.55228 15 9 15H7C6.44772 15 6 14.5523 6 14Z" fill="#CED0D6"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8704 9.14748C12.0417 8.27221 12.8 6.87465 12.8 5.3C12.8 2.64903 10.6509 0.5 7.99995 0.5C5.34898 0.5 3.19995 2.64903 3.19995 5.3C3.19995 6.87464 3.95817 8.27218 5.12943 9.14746L5.49994 11H10.4999L10.8704 9.14748Z" fill="#DB5C5C"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 670 B |
@@ -247,6 +247,7 @@ const ICONS = {
|
||||
list: "general/listFiles",
|
||||
keyboard: "general/keyboard",
|
||||
bulb: "codeInsight/intentionBulb",
|
||||
"bulb-fix": "codeInsight/quickfixBulb",
|
||||
package: "nodes/package",
|
||||
plugin: "nodes/plugin",
|
||||
"soft-wrap": "general/softWrap",
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from "./Input";
|
||||
export * from "./Checkbox";
|
||||
export * from "./Select";
|
||||
export * from "./Tabs";
|
||||
export { FILES_DRAG, dragRequiresAlt, dragSession, registerDropTarget, setDragRequiresAlt, startPointerDrag, type DragData, type DragSession, type DropTarget } from "./pointerDrag";
|
||||
export * from "./Tree";
|
||||
export * from "./List";
|
||||
export * from "./Popup";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.ghost {
|
||||
position: fixed;
|
||||
z-index: var(--z-tooltip);
|
||||
margin: 0;
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
box-shadow: var(--shadow-tooltip);
|
||||
background: var(--bg-popup);
|
||||
}
|
||||
|
||||
.noTarget {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.dragging,
|
||||
.dragging * {
|
||||
cursor: grabbing !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Pointer-driven drag & drop between components. HTML5 drag and drop can't be
|
||||
* used: with the window's native file drop enabled, the webview never sees
|
||||
* dragover / drop for drags that start inside it. Sources start a drag with
|
||||
* `startPointerDrag` (or drive a `DragSession` themselves); targets register
|
||||
* an element with `registerDropTarget` and are told about drags over it.
|
||||
*/
|
||||
import styles from "./pointerDrag.module.css";
|
||||
|
||||
/** What is dragged: `type` is matched by targets (e.g. `application/x-openidea-editor-tab`). */
|
||||
export interface DragData {
|
||||
type: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
/** Project files dragged from the Project view: `payload` is the paths joined by `\n`. */
|
||||
export const FILES_DRAG = "application/x-openidea-files";
|
||||
|
||||
export interface DropTarget {
|
||||
accepts(type: string): boolean;
|
||||
/** The pointer moved over the target. */
|
||||
over(data: DragData, x: number, y: number): void;
|
||||
/** The pointer left the target, or the drag ended elsewhere / was cancelled. */
|
||||
leave(): void;
|
||||
drop(data: DragData, x: number, y: number): void;
|
||||
}
|
||||
|
||||
const targets = new Map<Element, DropTarget>();
|
||||
|
||||
export function registerDropTarget(el: Element, target: DropTarget): () => void {
|
||||
targets.set(el, target);
|
||||
return () => {
|
||||
if (targets.get(el) === target) targets.delete(el);
|
||||
};
|
||||
}
|
||||
|
||||
/** The innermost registered target under (x, y) accepting `type`. */
|
||||
function targetAt(type: string, x: number, y: number): DropTarget | null {
|
||||
for (let el = document.elementFromPoint(x, y); el; el = el.parentElement) {
|
||||
const t = targets.get(el);
|
||||
if (t?.accepts(type)) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface DragSession {
|
||||
/** Updates hover feedback; true when a target under (x, y) accepts the drag. */
|
||||
move(x: number, y: number): boolean;
|
||||
/** Drops on the target under (x, y); false when there is none. */
|
||||
drop(x: number, y: number): boolean;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function dragSession(data: DragData): DragSession {
|
||||
let current: DropTarget | null = null;
|
||||
const enter = (t: DropTarget | null) => {
|
||||
if (t === current) return;
|
||||
current?.leave();
|
||||
current = t;
|
||||
};
|
||||
return {
|
||||
move(x, y) {
|
||||
enter(targetAt(data.type, x, y));
|
||||
current?.over(data, x, y);
|
||||
return !!current;
|
||||
},
|
||||
drop(x, y) {
|
||||
const t = targetAt(data.type, x, y);
|
||||
enter(null);
|
||||
t?.drop(data, x, y);
|
||||
return !!t;
|
||||
},
|
||||
cancel: () => enter(null),
|
||||
};
|
||||
}
|
||||
|
||||
/** Movement before a press becomes a drag, px. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
/** Settings › Appearance › "Drag-and-drop with Alt pressed only" for tabs and stripe buttons. */
|
||||
let requiresAlt = () => false;
|
||||
export function setDragRequiresAlt(fn: () => boolean) {
|
||||
requiresAlt = fn;
|
||||
}
|
||||
export const dragRequiresAlt = () => requiresAlt();
|
||||
|
||||
/**
|
||||
* Starts a drag from a primary-button press on `source` once the pointer moved
|
||||
* past the threshold; a ghost copy of `source` follows the pointer. Escape
|
||||
* cancels. `onEnd` gets whether the drag happened (to swallow the click).
|
||||
*/
|
||||
export function startPointerDrag(
|
||||
e: { button: number; clientX: number; clientY: number; altKey: boolean },
|
||||
source: HTMLElement,
|
||||
data: DragData,
|
||||
opts: { onStart?: () => void; onEnd?: (dragged: boolean) => void } = {},
|
||||
) {
|
||||
if (e.button !== 0 || (requiresAlt() && !e.altKey)) return;
|
||||
const start = { x: e.clientX, y: e.clientY };
|
||||
let ghost: HTMLElement | null = null;
|
||||
const session = dragSession(data);
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!ghost) {
|
||||
if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < DRAG_THRESHOLD) return;
|
||||
const r = source.getBoundingClientRect();
|
||||
ghost = source.cloneNode(true) as HTMLElement;
|
||||
ghost.removeAttribute("id");
|
||||
ghost.querySelectorAll("[id]").forEach((n) => n.removeAttribute("id"));
|
||||
ghost.classList.add(styles.ghost!);
|
||||
ghost.setAttribute("aria-hidden", "true");
|
||||
Object.assign(ghost.style, {
|
||||
left: `${r.left}px`,
|
||||
top: `${r.top}px`,
|
||||
width: `${r.width}px`,
|
||||
height: `${r.height}px`,
|
||||
});
|
||||
document.body.appendChild(ghost);
|
||||
document.documentElement.classList.add(styles.dragging!);
|
||||
opts.onStart?.();
|
||||
}
|
||||
ghost.style.transform = `translate(${ev.clientX - start.x}px, ${ev.clientY - start.y}px)`;
|
||||
ghost.classList.toggle(styles.noTarget!, !session.move(ev.clientX, ev.clientY));
|
||||
};
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
const dragged = !!ghost;
|
||||
cleanup();
|
||||
if (dragged) session.drop(ev.clientX, ev.clientY);
|
||||
opts.onEnd?.(dragged);
|
||||
};
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== "Escape" || !ghost) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
session.cancel();
|
||||
cleanup();
|
||||
opts.onEnd?.(true);
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onCancel);
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
ghost?.remove();
|
||||
document.documentElement.classList.remove(styles.dragging!);
|
||||
};
|
||||
const onCancel = () => {
|
||||
session.cancel();
|
||||
const dragged = !!ghost;
|
||||
cleanup();
|
||||
opts.onEnd?.(dragged);
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onCancel);
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача