дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +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
@@ -52,6 +52,7 @@ import {
|
||||
removePane,
|
||||
reopenClosed,
|
||||
setActivePane,
|
||||
splitAtEdge,
|
||||
sortTabs,
|
||||
splitPane,
|
||||
tabOptions,
|
||||
@@ -784,7 +785,7 @@ export class EditorController implements SlotHost {
|
||||
this.navSuppress++;
|
||||
try {
|
||||
let s = useTabs.getState();
|
||||
let paneId = s.activePane;
|
||||
let paneId = o.pane && s.panes[o.pane] ? o.pane : s.activePane;
|
||||
if (o.side) {
|
||||
const right = neighbourPane(s, paneId);
|
||||
if (right) paneId = right;
|
||||
@@ -799,7 +800,7 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
const found = findTab(s, input.key, paneId);
|
||||
let tabId: string;
|
||||
if (found && (!o.side || found.paneId === paneId)) {
|
||||
if (found && (!(o.side || o.pane) || found.paneId === paneId)) {
|
||||
paneId = found.paneId;
|
||||
s = activateTab(setActivePane(s, paneId), paneId, found.tab.id);
|
||||
if (!o.preview) s = promoteTab(s, paneId, found.tab.id);
|
||||
@@ -935,6 +936,20 @@ export class EditorController implements SlotHost {
|
||||
if (slot?.view) slot.focus();
|
||||
else if (slot) slot.wantsFocus = true;
|
||||
}
|
||||
/** Files dropped on an editor: opened in that pane, or in a new split at `edge`. */
|
||||
async openFilesIn(paths: readonly string[], paneId: string, edge: SplitEdge | "center") {
|
||||
const s = useTabs.getState();
|
||||
if (!s.panes[paneId] || !paths.length) return;
|
||||
let target = paneId;
|
||||
if (edge !== "center") {
|
||||
const r = splitAtEdge(s, paneId, edge);
|
||||
useTabs.setState(r.state, true);
|
||||
target = r.paneId;
|
||||
}
|
||||
for (const [i, path] of paths.entries()) await this.openFile(path, { pane: target, focus: i === paths.length - 1 });
|
||||
const pane = useTabs.getState().panes[target];
|
||||
if (pane && !pane.tabs.length) this.apply((st) => removePane(st, target));
|
||||
}
|
||||
/** IDEA "Move to Opposite Group"; without a split, "Split and Move Right". */
|
||||
moveToOpposite(paneId: string, tabId: string) {
|
||||
const target = neighbourPane(useTabs.getState(), paneId);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialTabsState, moveTabToNewSplit, openTab, paneIds, splitPane, togglePin, type TabsState } from "./tabs";
|
||||
import { initialTabsState, moveTabToNewSplit, openTab, paneIds, splitAtEdge, splitPane, togglePin, type TabsState } from "./tabs";
|
||||
import { loadSession, parseSession, restoreTabsState, serializeSession, slashPath } from "./session";
|
||||
|
||||
const file = (path: string) => ({ kind: "file", key: `file:${path}`, path });
|
||||
@@ -145,3 +145,15 @@ describe("sessions saved with Windows backslash paths", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitAtEdge", () => {
|
||||
it("adds an empty active pane on the given side", () => {
|
||||
const s = open(initialTabsState(), "/a");
|
||||
const pane = s.activePane;
|
||||
const r = splitAtEdge(s, pane, "right");
|
||||
expect(paneIds(r.state)).toEqual([pane, r.paneId]);
|
||||
expect(r.state.panes[r.paneId]!.tabs).toEqual([]);
|
||||
expect(r.state.activePane).toBe(r.paneId);
|
||||
expect(paths(r.state, pane)).toEqual(["/a"]);
|
||||
});
|
||||
});
|
||||
@@ -425,21 +425,29 @@ export function moveTabToNewSplit(
|
||||
const tab = src?.tabs.find((t) => t.id === tabId);
|
||||
if (!src || !tab || !s.panes[targetPane]) return null;
|
||||
if (fromPane === targetPane && src.tabs.length === 1) return null;
|
||||
let next = { ...removeTabs(s, fromPane, new Set([tabId])), closed: s.closed };
|
||||
const next = { ...removeTabs(s, fromPane, new Set([tabId])), closed: s.closed };
|
||||
// The source pane may have collapsed away; split the target if it survived, else the active pane.
|
||||
const anchor = next.panes[targetPane] ? targetPane : next.activePane;
|
||||
const id = nextId("pane");
|
||||
const moved: Tab = { ...tab, preview: false };
|
||||
const pane: Pane = { id, tabs: [moved], active: moved.id, mru: [moved.id] };
|
||||
return splitAtEdge(next, anchor, edge, { tabs: [moved], active: moved.id, mru: [moved.id] });
|
||||
}
|
||||
|
||||
/** A new pane (empty unless `content` is given) at `edge` of `anchor`, made active. */
|
||||
export function splitAtEdge(
|
||||
s: TabsState,
|
||||
anchor: string,
|
||||
edge: SplitEdge,
|
||||
content: Omit<Pane, "id"> = { tabs: [], active: null, mru: [] },
|
||||
): { state: TabsState; paneId: string } {
|
||||
const id = nextId("pane");
|
||||
const dir = edge === "left" || edge === "right" ? "row" : "column";
|
||||
const before = edge === "left" || edge === "top";
|
||||
const layout = mapLayout(next.layout, (n) => {
|
||||
const layout = mapLayout(s.layout, (n) => {
|
||||
if (n.kind === "pane" && n.paneId === anchor) {
|
||||
const added: LayoutNode = { kind: "pane", paneId: id };
|
||||
return { kind: "split", id: nextId("split"), dir, children: before ? [added, n] : [n, added] };
|
||||
}
|
||||
return n;
|
||||
})!;
|
||||
next = { ...next, layout, panes: { ...next.panes, [id]: pane }, activePane: id };
|
||||
return { state: next, paneId: id };
|
||||
return { state: { ...s, layout, panes: { ...s.panes, [id]: { id, ...content } }, activePane: id }, paneId: id };
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* decorations, middle-click close, Alt+click close others, double-click to
|
||||
* keep a preview / maximize the editor, context menu) and the active editor.
|
||||
*/
|
||||
import { Fragment, useMemo, useRef, useState, type DragEvent, type FocusEvent, type ReactNode } from "react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState, type FocusEvent, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
SplitPanel,
|
||||
Tabs,
|
||||
showContextMenu,
|
||||
registerDropTarget,
|
||||
FILES_DRAG,
|
||||
type TabItem,
|
||||
type TabsDnd,
|
||||
} from "@/ui";
|
||||
@@ -197,6 +199,33 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
return { all: strip(0), rest: strip(pinnedCount) };
|
||||
}, [ctrl, paneId, pinnedCount]);
|
||||
|
||||
// Dropping a tab or project files on the editor: an edge splits, the middle opens / moves here.
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const hasPane = !!pane;
|
||||
useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const zoneAt = (x: number, y: number) => dropZoneAt(x, y, el.getBoundingClientRect());
|
||||
return registerDropTarget(el, {
|
||||
accepts: (type) => type === EDITOR_TAB_MIME || type === FILES_DRAG,
|
||||
over: (_, x, y) => setDropZone(zoneAt(x, y)),
|
||||
leave: () => setDropZone(null),
|
||||
drop: (data, x, y) => {
|
||||
const zone = zoneAt(x, y);
|
||||
setDropZone(null);
|
||||
if (data.type === FILES_DRAG) {
|
||||
void ctrl.openFilesIn(data.payload.split("\n").filter(Boolean), paneId, zone);
|
||||
return;
|
||||
}
|
||||
const p = parseTabPayload(data.payload);
|
||||
if (!p) return;
|
||||
const target = useTabs.getState().panes[paneId];
|
||||
if (zone === "center") ctrl.moveTab(p.paneId, p.tabId, paneId, target?.tabs.length ?? 0);
|
||||
else ctrl.moveTabToSplit(p.paneId, p.tabId, paneId, zone);
|
||||
},
|
||||
});
|
||||
}, [ctrl, paneId, hasPane]);
|
||||
|
||||
if (!pane) return null;
|
||||
const active = pane.tabs.find((t) => t.id === pane.active) ?? null;
|
||||
|
||||
@@ -208,33 +237,6 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setContext("editorFocus", false);
|
||||
};
|
||||
|
||||
// Dropping a tab on the editor: an edge splits, the middle moves the tab here.
|
||||
const isTabDrag = (e: DragEvent) => [...e.dataTransfer.types].includes(EDITOR_TAB_MIME);
|
||||
const contentDnd = {
|
||||
onDragOverCapture: (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isTabDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const zone = dropZoneAt(e.clientX, e.clientY, e.currentTarget.getBoundingClientRect());
|
||||
if (zone !== dropZone) setDropZone(zone);
|
||||
},
|
||||
onDragLeave: (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropZone(null);
|
||||
},
|
||||
onDropCapture: (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isTabDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const zone = dropZoneAt(e.clientX, e.clientY, e.currentTarget.getBoundingClientRect());
|
||||
setDropZone(null);
|
||||
const p = parseTabPayload(e.dataTransfer.getData(EDITOR_TAB_MIME));
|
||||
if (!p) return;
|
||||
if (zone === "center") ctrl.moveTab(p.paneId, p.tabId, paneId, pane.tabs.length);
|
||||
else ctrl.moveTabToSplit(p.paneId, p.tabId, paneId, zone);
|
||||
},
|
||||
};
|
||||
|
||||
const hidden = tabPrefs.placement === "none" || distractionFree;
|
||||
const strip = (stripItems: TabItem[], stripDnd: TabsDnd, trailing: ReactNode) => (
|
||||
<Tabs
|
||||
@@ -299,7 +301,7 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
role="tabpanel"
|
||||
id={active ? `editor-${paneId}-panel-${active.id}` : undefined}
|
||||
aria-labelledby={active ? `editor-${paneId}-tab-${active.id}` : undefined}
|
||||
{...contentDnd}
|
||||
ref={contentRef}
|
||||
>
|
||||
{active ? <PaneContent paneId={paneId} tab={active} /> : <EmptyEditor />}
|
||||
{dropZone && <div className={clsx(styles.dropZone, styles[`drop-${dropZone}`])} aria-hidden="true" />}
|
||||
|
||||
Reference in new issue
Заблокувати користувача