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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-28 09:07:50 +08:00
1 джерело 48ac2094fc
коміт 357b728f1d
71 files changed
+1630 -197

No files matched your search

+17 -2
Переглянути файл
@@ -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);
+13 -1
Переглянути файл
@@ -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"]);
});
});
+14 -6
Переглянути файл
@@ -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 };
}
+31 -29
Переглянути файл
@@ -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" />}