Files
openidea/src/editor/core/controller.ts
T
l0ng-ai 357b728f1d 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
2026-10-28 09:07:50 +08:00

1280 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Editor controller: tabs/splits store, editor slots, focus + context keys,
* status, navigation history, autosave. The React views and actions talk to
* this singleton; other features use the EditorService and editor/api.
*/
import { EditorSelection } from "@codemirror/state";
import { create } from "zustand";
import { setContext } from "@/platform/context";
import { shortcutLabel } from "@/platform/keymap";
import { basename, useProject } from "@/platform/project";
import { editorPrefs } from "@/platform/editorSettings";
import {
_emitEditorEvent,
_provideNavigationHistory,
_provideOpenEditorPaths,
_setActiveEditor,
type NavigationLocation,
} from "@/editor/api";
import {
getFileDecoration,
menuGroupItems,
notifications,
resolveFileEditor,
useContributions,
type EditorInput,
type OpenOptions,
type OpenEditorTab,
} from "@/workbench/contributions";
import { useDistractionFree } from "@/workbench/distractionFree";
import { fileIconName, showContextMenu, type MenuItem } from "@/ui";
import { usePlatform } from "@/platform/platformInfo";
import {
activateTab,
closeAll,
closeKey,
closeOthers,
closeTab,
closeToLeft,
closeToRight,
closeUnmodified,
cycleTab,
enforceTabLimit,
findTab,
initialTabsState,
moveTab,
moveTabToNewSplit,
neighbourPane,
openTab,
paneIds,
paneOf,
promoteTab,
removePane,
reopenClosed,
setActivePane,
splitAtEdge,
sortTabs,
splitPane,
tabOptions,
togglePin,
type SplitEdge,
type Tab,
type TabsState,
} from "./model/tabs";
import { DocumentManager, useDocMeta } from "./model/document";
import { EditorSlot, type SlotHost } from "./model/slot";
import { NavigationHistory } from "./model/navHistory";
import { EditHistory } from "./model/editHistory";
import { loadSession, restoreTabsState, serializeSession, storeSession, type FileViewState } from "./model/session";
import { docBackend, type DocBackend } from "./backend";
import { isVirtualUri, virtualDocument } from "@/platform/virtualDocs";
import type { VirtualDocument } from "@/platform/ipc";
import type { Scheduler } from "./sync/DocSync";
export const fileKey = (path: string) => `file:${path}`;
export const fileInput = (path: string): EditorInput => ({ kind: "file", key: fileKey(path), path });
/** `data` of a `virtual` editor input: what the tab and its notice show. */
export interface VirtualInputData {
title: string;
description: string | null;
decompiled: boolean;
}
export const virtualKey = (uri: string) => `virtual:${uri}`;
/**
* A read-only virtual document (library class from a language server). Its
* `path` is the URI, which also identifies its document model.
*/
export const virtualInput = (
vd: Pick<VirtualDocument, "uri" | "title" | "description" | "decompiled">,
): EditorInput => ({
kind: "virtual",
key: virtualKey(vd.uri),
path: vd.uri,
data: { title: vd.title, description: vd.description, decompiled: vd.decompiled } satisfies VirtualInputData,
});
/** Inputs shown in the text editor: files and virtual documents. */
export function isTextInput(input: EditorInput): input is EditorInput & { path: string } {
return (input.kind === "file" || input.kind === "virtual") && !!input.path;
}
export const useTabs = create<TabsState>(() => initialTabsState());
export interface EditorStatus {
path: string;
doc: number;
/** 0-based */
line: number;
col: number;
offset: number;
selectedChars: number;
selectedLines: number;
carets: number;
}
export const useEditorStatus = create<{ status: EditorStatus | null }>(() => ({ status: null }));
/** Per-tab open errors (file missing, too large…). */
export const useTabErrors = create<Record<string, string>>(() => ({}));
/** Per-pane find bar state (open / replace mode); the bar keeps its own query. */
export const useFindBars = create<Record<string, { replace: boolean; seq: number } | undefined>>(() => ({}));
export const useGotoLine = create<{ open: boolean }>(() => ({ open: false }));
/** Paths of open file tabs, all panes in visual order, unique. */
export function openFilePaths(s: TabsState): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const id of paneIds(s))
for (const t of s.panes[id]?.tabs ?? [])
if (t.input.kind === "file" && t.input.path && !seen.has(t.input.path)) {
seen.add(t.input.path);
out.push(t.input.path);
}
return out;
}
/** Editor Tabs › Sort tabs alphabetically: by file name. */
function tabSortKey(input: EditorInput): string {
return basename(input.path ?? input.key);
}
function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: string } } {
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>();
private pending = new Map<string, Promise<EditorSlot | null>>();
readonly nav = new NavigationHistory();
readonly edits = new EditHistory();
private navSuppress = 0;
activeSlot: EditorSlot | null = null;
private statusScheduled = false;
private disposers: Array<() => void> = [];
/** Project root whose editor session is being tracked (null while switching). */
private sessionRoot: string | null = null;
private sessionTimer: ReturnType<typeof setTimeout> | undefined;
/** Restored caret / scroll per path, applied when the file's first slot is created. */
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(
backend,
{
settings: () => useProject.getState().settings ?? useProject.getState().project?.settings,
notify: (n) => notifications().notify(n),
},
scheduler,
);
}
start() {
this.manager.start();
_provideNavigationHistory(() => this.nav.snapshot());
_provideOpenEditorPaths(() => openFilePaths(useTabs.getState()));
let prevTabs = useTabs.getState();
this.disposers.push(
useTabs.subscribe((s) => {
const prev = prevTabs;
prevTabs = s;
this.stampActive(s);
this.onTabsChanged(prev, s);
}),
);
tabOptions.activateOnClose = editorPrefs().tabs.activateOnClose as typeof tabOptions.activateOnClose;
let prevSettings = useProject.getState().settings;
let prevRoot = useProject.getState().project?.root ?? null;
this.disposers.push(
useProject.subscribe((s) => {
if (s.settings !== prevSettings) {
prevSettings = s.settings;
tabOptions.activateOnClose = editorPrefs(s.settings).tabs
.activateOnClose as typeof tabOptions.activateOnClose;
this.manager.applySettings();
}
const root = s.project?.root ?? null;
if (root !== prevRoot) {
prevRoot = root;
this.saveSession();
this.sessionRoot = null;
this.closeEverything();
this.nav.clear();
this.edits.clear();
if (root) this.restoreSession(root);
}
}),
);
// Gutter, centred column and sticky lines follow Distraction Free Mode.
this.disposers.push(useDistractionFree.subscribe(() => this.manager.applySettings()));
if (prevRoot) this.restoreSession(prevRoot);
if (this.manager.backend.onFsChanged)
this.disposers.push(this.manager.backend.onFsChanged((e) => this.onFilesRemoved(e.removed)));
const onWindowBlur = () => {
if (this.autoSave()) void this.manager.saveAll("auto");
};
const onPageHide = () => this.saveSession();
if (typeof window !== "undefined") {
window.addEventListener("blur", onWindowBlur);
window.addEventListener("pagehide", onPageHide);
window.addEventListener("beforeunload", onPageHide);
this.disposers.push(() => {
window.removeEventListener("blur", onWindowBlur);
window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("beforeunload", onPageHide);
});
}
this.publishContext();
}
stop() {
this.saveSession();
this.sessionRoot = null;
clearTimeout(this.sessionTimer);
this.disposers.splice(0).forEach((d) => d());
for (const slot of this.slots.values()) slot.dispose();
this.slots.clear();
this.manager.stop();
}
autoSave(): boolean {
return this.manager.settings()?.files?.autoSave !== false;
}
// --- tab store reactions -------------------------------------------------
private onTabsChanged(prev: TabsState, next: TabsState) {
const live = new Set<string>();
for (const p of Object.values(next.panes)) for (const t of p.tabs) live.add(t.id);
for (const [id, slot] of this.slots) {
if (!live.has(id)) {
this.slots.delete(id);
if (this.activeSlot === slot) this.setActiveSlot(null);
slot.dispose();
}
}
for (const id of Object.keys(useTabErrors.getState()))
if (!live.has(id))
useTabErrors.setState((s) => {
const n = { ...s };
delete n[id];
return n;
}, true);
// Autosave on tab switch (IDEA).
if (this.autoSave()) {
for (const [paneId, pane] of Object.entries(prev.panes)) {
const now = next.panes[paneId];
if (pane.active && now && now.active !== pane.active) {
const slot = this.slots.get(pane.active);
if (slot) void slot.model.save("auto").catch(() => {});
}
}
}
// Active editor follows the active pane's active tab.
const tab = this.activeTabOf(next);
const slot = tab && isTextTab(tab) ? (this.slots.get(tab.id) ?? null) : null;
if (slot !== this.activeSlot) this.setActiveSlot(slot, tab);
this.publishContext();
for (const id of this.restoredTabs) if (!live.has(id)) this.restoredTabs.delete(id);
this.scheduleSessionSave();
}
// --- session (open tabs, splits, carets per project) ---------------------------
private scheduleSessionSave() {
if (!this.sessionRoot) return;
clearTimeout(this.sessionTimer);
this.sessionTimer = setTimeout(() => this.saveSession(), 400);
}
/** Writes the editor session of the tracked project now. */
saveSession() {
clearTimeout(this.sessionTimer);
const root = this.sessionRoot;
if (!root) return;
const files: Record<string, FileViewState> = Object.fromEntries(this.restoredViews);
for (const slot of this.slots.values()) {
if (slot.disposed) continue;
const path = slot.model.path;
// The last focused slot of a document wins over other split copies.
if (files[path] && slot.model.lastFocused !== slot && !this.restoredViews.has(path)) continue;
files[path] = slot.viewState();
}
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). */
restoreSession(root: string) {
this.sessionRoot = root;
const session = loadSession(root);
if (!session) return;
const state = restoreTabsState(session, fileKey);
this.restoredViews = new Map(Object.entries(session.files));
this.restoredTabs = new Set(Object.values(state.panes).flatMap((p) => p.tabs.map((t) => t.id)));
useTabs.setState(state, true);
for (const pane of Object.values(state.panes)) if (pane.active) void this.ensureSlot(pane.active);
}
private activeTabOf(s: TabsState = useTabs.getState()): Tab | null {
const pane = s.panes[s.activePane];
return pane?.tabs.find((t) => t.id === pane.active) ?? null;
}
// --- slots -------------------------------------------------------------------
locateTab(tabId: string): { paneId: string; tab: Tab } | null {
const s = useTabs.getState();
for (const [paneId, p] of Object.entries(s.panes)) {
const tab = p.tabs.find((t) => t.id === tabId);
if (tab) return { paneId, tab };
}
return null;
}
slotFor(tabId: string): EditorSlot | null {
return this.slots.get(tabId) ?? null;
}
/** Creates the slot of a file tab (opening its document) if needed. */
ensureSlot(tabId: string, init?: { from?: EditorSlot }): Promise<EditorSlot | null> {
const existing = this.slots.get(tabId);
if (existing) return Promise.resolve(existing);
const pending = this.pending.get(tabId);
if (pending) return pending;
const loc = this.locateTab(tabId);
if (!loc || !isTextTab(loc.tab)) return Promise.resolve(null);
const path = loc.tab.input.path;
const model = this.manager.get(path);
if (model) return Promise.resolve(this.createSlot(tabId, model, init));
const p = this.manager
.acquire(path)
.then((m) => {
if (!this.locateTab(tabId)) {
this.manager.release(m);
return null;
}
return this.slots.get(tabId) ?? this.createSlot(tabId, m, init);
})
.catch((e: unknown) => {
if (this.restoredTabs.has(tabId) && (e as { kind?: string } | null)?.kind === "notFound") {
// A file from the saved session no longer exists: drop its tab quietly.
this.restoredTabs.delete(tabId);
this.restoredViews.delete(path);
const loc = this.locateTab(tabId);
if (loc) this.apply((st) => (st.panes[loc.paneId] ? closeTab(st, loc.paneId, tabId) : st));
return null;
}
const message = e instanceof Error ? e.message : String(e);
useTabErrors.setState({ [tabId]: message });
return null;
})
.finally(() => this.pending.delete(tabId));
this.pending.set(tabId, p);
return p;
}
private createSlot(tabId: string, model: import("./model/document").DocModel, init?: { from?: EditorSlot }) {
// Copy caret/scroll from another slot of the same document when splitting.
const from = init?.from ?? undefined;
const slot = new EditorSlot(model, tabId, this, { from });
this.slots.set(tabId, slot);
const restored = this.restoredViews.get(model.path);
if (restored && !from) {
this.restoredViews.delete(model.path);
slot.restoreView(restored);
}
this.restoredTabs.delete(tabId);
const active = this.activeTabOf();
if (active?.id === tabId) this.setActiveSlot(slot);
return slot;
}
/** Called by the view host after mounting. */
onMounted(slot: EditorSlot) {
const active = this.activeTabOf();
if (active?.id === slot.tabId) this.setActiveSlot(slot);
if (slot.wantsFocus) {
slot.wantsFocus = false;
requestAnimationFrame(() => slot.focus());
}
}
private setActiveSlot(slot: EditorSlot | null, nonTextTab: Tab | null = null) {
const changed = slot !== this.activeSlot;
this.activeSlot = slot;
_setActiveEditor(slot?.handle ?? null);
if (changed) {
_emitEditorEvent({
type: "activeEditorChanged",
path: slot?.model.path ?? nonTextTab?.input.path ?? null,
doc: slot?.model.doc ?? null,
});
if (slot) this.recordNav(slot, true);
}
this.scheduleStatus();
}
// --- SlotHost ------------------------------------------------------------------
onFocus(slot: EditorSlot) {
slot.model.lastFocused = slot;
const loc = this.locateTab(slot.tabId);
if (loc) {
const s = useTabs.getState();
const next = activateTab(setActivePane(s, loc.paneId), loc.paneId, slot.tabId);
if (next !== s) useTabs.setState(next, true);
}
if (this.activeSlot !== slot) this.setActiveSlot(slot);
else _setActiveEditor(slot.handle);
setContext("editorTextFocus", true);
setContext("editorFocus", true);
this.publishContext();
}
onBlur(slot: EditorSlot) {
setContext("editorTextFocus", false);
if (this.autoSave()) void slot.model.save("auto").catch(() => {});
}
onSelection(slot: EditorSlot, navigatedFrom?: { line: number; col: number }) {
if (slot !== this.activeSlot) return;
if (navigatedFrom && this.navSuppress === 0 && !slot.disposed) {
// The place left behind is where the caret was right before the navigation.
this.nav.update({ path: slot.model.path, ...navigatedFrom });
this.recordNav(slot, true);
}
// The status bar reflects the view state synchronously (programmatic and
// synthetic selection changes included); events / history are throttled.
this.publishStatus(slot);
this.scheduleStatus();
this.scheduleSessionSave();
}
onUserEdit(slot: EditorSlot) {
if (!slot.model.virtual) {
const c = slot.caret();
this.edits.record({ path: slot.model.path, line: c.line, col: c.col });
}
const loc = this.locateTab(slot.tabId);
if (loc?.tab.preview) useTabs.setState((s) => promoteTab(s, loc.paneId, slot.tabId), true);
}
onEscape(slot: EditorSlot): boolean {
const loc = this.locateTab(slot.tabId);
if (loc && useFindBars.getState()[loc.paneId]) {
useFindBars.setState({ [loc.paneId]: undefined });
return true;
}
return false;
}
onContextMenu(slot: EditorSlot, e: MouseEvent) {
e.preventDefault();
const view = slot.view;
if (!view) return;
// Right-click outside the selection moves the caret, like IDEA.
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY });
if (pos !== null && !view.state.selection.ranges.some((r) => r.from <= pos && pos <= r.to))
view.dispatch({ selection: EditorSelection.cursor(pos) });
const hasSel = !view.state.selection.main.empty;
const items: MenuItem[] = [
{ kind: "action", action: "code.showIntentions", label: "Show Context Actions", icon: "bulb" },
{ kind: "separator" },
// Like IDEA, Cut / Copy only with a selection.
...((hasSel
? [
{
label: "Cut",
icon: "cut",
shortcut: "⌘X",
disabled: view.state.readOnly,
run: () => {
view.focus();
document.execCommand("cut");
},
},
{
label: "Copy",
icon: "copy",
shortcut: "⌘C",
run: () => {
view.focus();
document.execCommand("copy");
},
},
]
: []) as MenuItem[]),
{
label: "Paste",
icon: "paste",
shortcut: "⌘V",
disabled: view.state.readOnly,
run: () => {
void navigator.clipboard
?.readText()
.then((text) => {
view.dispatch(view.state.replaceSelection(text), { userEvent: "input.paste", scrollIntoView: true });
view.focus();
})
.catch(() => {
view.focus();
document.execCommand("paste");
});
},
},
{ kind: "separator" },
{
label: "Copy / Paste Special",
submenu: [
{ label: "Copy Path", run: () => void navigator.clipboard?.writeText(slot.model.path) },
{ kind: "action", action: "code.pasteFromHistory" },
],
},
{ kind: "action", action: "editor.toggleColumnSelection", label: "Column Selection Mode" },
{ kind: "separator" },
// IDEA's editor popup: secondary commands live in submenus.
{
label: "Go To",
submenu: [
{ kind: "action", action: "navigate.declaration", label: "Declaration or Usages" },
{ kind: "action", action: "navigate.implementation", label: "Implementation(s)" },
{ kind: "action", action: "navigate.typeDeclaration", label: "Type Declaration" },
{ kind: "action", action: "navigate.superMethod", label: "Super Method" },
{ kind: "action", action: "navigate.gotoTest", label: "Test" },
{ kind: "separator" },
{ kind: "action", action: "find.usages", label: "Find Usages" },
{ kind: "action", action: "navigate.callHierarchy", label: "Call Hierarchy" },
{ kind: "action", action: "navigate.typeHierarchy", label: "Type Hierarchy" },
],
},
{ kind: "separator" },
{
label: "Folding",
submenu: [
{ kind: "action", action: "code.unfold", label: "Expand" },
{ kind: "action", action: "code.fold", label: "Collapse" },
{ kind: "action", action: "code.unfoldAll", label: "Expand All" },
{ kind: "action", action: "code.foldAll", label: "Collapse All" },
{ kind: "separator" },
{ kind: "action", action: "code.foldSelection", label: "Fold Selection / Remove Region" },
],
},
{ kind: "separator" },
{ kind: "action", action: "refactor.rename", label: "Rename…" },
{
label: "Refactor",
submenu: [
{ kind: "action", action: "refactor.thisMenu", label: "Refactor This…" },
{ kind: "separator" },
{ kind: "action", action: "refactor.move" },
{ kind: "action", action: "refactor.changeSignature" },
{ kind: "action", action: "refactor.extractVariable" },
{ kind: "action", action: "refactor.extractConstant" },
{ kind: "action", action: "refactor.extractMethod" },
{ kind: "action", action: "refactor.inline" },
{ kind: "separator" },
{ kind: "action", action: "refactor.safeDelete" },
],
},
{ kind: "action", action: "code.generate", label: "Generate…" },
{ kind: "separator" },
// Contributed groups (Run / Debug '…', More Run/Debug).
...menuGroupItems("editor", {
paths: [slot.model.path],
text: view.state.doc.toString(),
line: view.state.doc.lineAt(view.state.selection.main.head).number - 1,
}),
...(slot.model.virtual
? ([
{ kind: "separator" },
{ label: "Copy Reference", run: () => void navigator.clipboard?.writeText(slot.model.path) },
] as MenuItem[])
: ([
{ kind: "separator" },
openInMenu(slot.model.path),
{ kind: "separator" },
localHistoryMenu(slot.model.path),
gitMenu(slot.model.path),
{ kind: "separator" },
{
kind: "action",
action: "diff.compareWithClipboard",
arg: { path: slot.model.path },
label: "Compare with Clipboard",
},
] as MenuItem[])),
];
showContextMenu(e, items);
}
// --- status / context ------------------------------------------------------------
private scheduleStatus() {
if (this.statusScheduled) return;
this.statusScheduled = true;
let done = false;
const run = () => {
if (done) return;
done = true;
this.statusScheduled = false;
this.updateStatus();
};
// Animation frames pause while the window is hidden; the timeout keeps
// the status and caret events from going stale then.
if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
setTimeout(run, typeof requestAnimationFrame === "function" ? 100 : 0);
}
private updateStatus() {
const slot = this.activeSlot;
if (!slot || slot.disposed) {
useEditorStatus.setState({ status: null });
this.publishContext();
return;
}
const status = this.publishStatus(slot);
_emitEditorEvent({
type: "caretMoved",
doc: status.doc,
path: status.path,
line: status.line,
col: status.col,
offset: status.offset,
});
this.recordNav(slot);
this.publishContext();
}
/** Computes the status of `slot` from its current (view) state and stores it if changed. */
private publishStatus(slot: EditorSlot): EditorStatus {
const status = computeStatus(slot);
const prev = useEditorStatus.getState().status;
if (!prev || !sameStatus(prev, status)) useEditorStatus.setState({ status });
return status;
}
publishContext() {
const slot = this.activeSlot;
const meta = slot?.model.getMeta();
const s = useTabs.getState();
setContext("editorPath", slot?.model.path ?? null);
setContext("editorLangId", meta?.language ?? null);
setContext("editorReadOnly", !!meta?.readOnly);
setContext("editorHasSelection", !!slot && slot.currentState().selection.ranges.some((r) => !r.empty));
setContext(
"editorTabsOpen",
Object.values(s.panes).some((p) => p.tabs.length > 0),
);
setContext("editorSplit", paneIds(s).length > 1);
setContext("textEditorActive", !!slot);
}
// --- navigation history --------------------------------------------------------------
/** `navigation`: the caret got here by a navigation (see NavigationHistory.update). */
private recordNav(slot: EditorSlot, navigation = false) {
if (this.navSuppress > 0 || slot.disposed) return;
const c = slot.caret();
this.nav.update({ path: slot.model.path, line: c.line, col: c.col }, navigation);
}
async navigateTo(loc: NavigationLocation | null) {
if (!loc) return;
this.navSuppress++;
try {
await this.openFile(loc.path, { selection: { line: loc.line, col: loc.col }, focus: true });
} finally {
this.navSuppress--;
if (this.activeSlot) this.recordNav(this.activeSlot);
}
}
back() {
if (this.activeSlot) this.recordNav(this.activeSlot);
return this.navigateTo(this.nav.back());
}
forward() {
return this.navigateTo(this.nav.forward());
}
/** Navigate › Last Edit Location (⇧⌘⌫): repeated, walks back through older edits. */
lastEditLocation() {
const slot = this.activeSlot;
const caret = slot && !slot.disposed ? { path: slot.model.path, ...slot.caret() } : null;
return this.goToEdit(this.edits.back(caret));
}
/** Navigate › Next Edit Location: back towards the newest edit. */
nextEditLocation() {
return this.goToEdit(this.edits.forward());
}
private async goToEdit(loc: { path: string; line: number; col: number } | null) {
if (!loc) return false;
if (this.activeSlot) this.recordNav(this.activeSlot);
await this.openFile(loc.path, { selection: { line: loc.line, col: loc.col }, focus: true });
return true;
}
// --- opening ---------------------------------------------------------------------------
async openFile(path: string, o: OpenOptions = {}) {
if (isVirtualUri(path)) return this.openVirtual(path, o);
// Images, binary and huge files open in a contributed viewer unless already open as text.
if (!o.asText && !findTab(useTabs.getState(), fileKey(path))) {
const viewer = await resolveFileEditor(path);
if (viewer) return this.openInput(viewer, o);
}
await this.openInput(fileInput(path), o);
}
/** Opens the library class (or other server-provided document) behind `uri` read-only. */
async openVirtual(uri: string, o: OpenOptions = {}) {
let vd: VirtualDocument;
try {
vd = await virtualDocument(uri);
} catch (e) {
notifications().notify({
level: "warning",
title: "Cannot open library class",
body: e instanceof Error ? e.message : String(e),
group: "lsp",
});
return;
}
await this.openInput(virtualInput(vd), o);
}
async openInput(input: EditorInput, o: OpenOptions = {}) {
this.navSuppress++;
try {
let s = useTabs.getState();
let paneId = o.pane && s.panes[o.pane] ? o.pane : s.activePane;
if (o.side) {
const right = neighbourPane(s, paneId);
if (right) paneId = right;
else {
const split = splitPane(s, paneId, "row");
s = split.state;
paneId = split.paneId;
// The new pane starts with only the requested input.
const pane = s.panes[paneId]!;
s = { ...s, panes: { ...s.panes, [paneId]: { ...pane, tabs: [], active: null, mru: [] } } };
}
}
const found = findTab(s, input.key, paneId);
let tabId: string;
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);
tabId = found.tab.id;
if (found.tab.input.data !== input.data && input.data !== undefined)
s = openTab(s, paneId, input, { preview: o.preview }).state;
} else {
const tabs = editorPrefs().tabs;
const preview = !!o.preview && tabs.previewTab;
const r = openTab(s, paneId, input, {
preview,
...(tabs.openAtEnd ? { index: s.panes[paneId]?.tabs.length ?? 0 } : {}),
});
s = r.state;
tabId = r.tabId;
if (tabs.sortAlphabetically) s = sortTabs(s, paneId, (t) => tabSortKey(t.input));
s = enforceTabLimit(
s,
paneId,
tabs.tabLimit,
tabs.closingPolicy,
(t) => !!(t.input.path && useDocMeta.getState()[t.input.path]?.dirty),
tabId,
);
}
useTabs.setState(s, true);
if (!isTextInput(input)) {
if (o.focus !== false) requestAnimationFrame(() => this.focus());
return;
}
const slot = await this.ensureSlot(tabId);
if (!slot) return;
if (o.selection) {
const state = slot.currentState();
const toOffset = (line: number, col: number) => {
const l = state.doc.line(Math.min(Math.max(1, line + 1), state.doc.lines));
return l.from + Math.min(Math.max(0, col), l.length);
};
const anchor = toOffset(o.selection.line, o.selection.col);
const head =
o.selection.endLine !== undefined ? toOffset(o.selection.endLine, o.selection.endCol ?? 0) : anchor;
// Selection runs anchor→head; place the caret at the start like IDEA's navigation.
slot.reveal({ anchor: head, head: anchor, center: true });
}
if (o.focus !== false) {
if (slot.view) slot.focus();
else slot.wantsFocus = true;
}
} finally {
this.navSuppress--;
if (this.navSuppress === 0 && this.activeSlot) this.recordNav(this.activeSlot, true);
}
}
activate(paneId: string, tabId: string, focus = true) {
useTabs.setState((s) => activateTab(setActivePane(s, paneId), paneId, tabId), true);
if (focus) {
const slot = this.slots.get(tabId);
if (slot?.view) slot.focus();
else if (slot) slot.wantsFocus = true;
else void this.ensureSlot(tabId).then((sl) => sl && (sl.view ? sl.focus() : (sl.wantsFocus = true)));
}
}
// --- tab commands -----------------------------------------------------------------------------
private apply(f: (s: TabsState) => TabsState) {
useTabs.setState((s) => f(s), true);
}
closeTab(paneId: string, tabId: string) {
this.apply((s) => (s.panes[paneId] ? closeTab(s, paneId, tabId) : s));
}
closeOthers(paneId: string, tabId: string) {
this.apply((s) => closeOthers(s, paneId, tabId));
}
closeAll(paneId: string, keepPinned = false) {
this.apply((s) => closeAll(s, paneId, keepPinned));
}
closeToRight(paneId: string, tabId: string) {
this.apply((s) => closeToRight(s, paneId, tabId));
}
closeToLeft(paneId: string, tabId: string) {
this.apply((s) => closeToLeft(s, paneId, tabId));
}
/** Keeps tabs with unsaved changes or a VCS status (IDEA: tabs shown as modified). */
closeUnmodified(paneId: string) {
const modified = (t: Tab) => {
const p = t.input.path;
return !!p && (!!useDocMeta.getState()[p]?.dirty || !!getFileDecoration(p)?.color);
};
this.apply((s) => closeUnmodified(s, paneId, modified));
}
/** Reload from Disk: re-reads the open, unmodified documents of `paths`. */
async reloadFromDisk(paths: string[]) {
for (const path of paths) {
const model = this.manager.get(path);
if (!model) continue;
const meta = model.getMeta();
if (meta.dirty) {
notifications().notify({
level: "warning",
title: `${path.slice(path.lastIndexOf("/") + 1)} has unsaved changes`,
body: "Save or roll back the changes before reloading it from disk.",
});
continue;
}
try {
const encoding = await this.manager.backend.setEncoding(meta.doc, meta.encoding, true);
model.setMeta({ encoding });
} catch (e) {
notifications().notify({ level: "error", title: "Cannot reload from disk", body: e instanceof Error ? e.message : String(e) });
}
}
}
togglePin(paneId: string, tabId: string) {
this.apply((s) => togglePin(s, paneId, tabId));
}
/** Turns a preview tab into a regular one (double-click on the tab). */
keepTab(paneId: string, tabId: string) {
this.apply((s) => (s.panes[paneId] ? promoteTab(s, paneId, tabId) : s));
}
/** Drag & drop; the tab keeps its id (and slot) when moved within a pane. */
moveTab(fromPane: string, tabId: string, toPane: string, index: number) {
this.apply((s) => moveTab(s, fromPane, tabId, toPane, index));
}
/** Drag & drop onto an editor edge: the tab moves into a new split on that side. */
moveTabToSplit(fromPane: string, tabId: string, targetPane: string, edge: SplitEdge) {
const r = moveTabToNewSplit(useTabs.getState(), fromPane, tabId, targetPane, edge);
if (!r) return;
useTabs.setState(r.state, true);
const slot = this.slots.get(tabId);
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);
if (!target) return this.moveTabToSplit(paneId, tabId, paneId, "right");
this.apply((s) => moveTab(s, paneId, tabId, target, paneOf(s, target).tabs.length));
this.focus();
}
cycle(delta: number) {
this.apply((s) => cycleTab(s, delta));
this.focus();
}
reopenClosed() {
const r = reopenClosed(useTabs.getState());
if (!r) return;
useTabs.setState(r.state, true);
void this.ensureSlot(r.tabId).then((slot) => slot && (slot.view ? slot.focus() : (slot.wantsFocus = true)));
}
split(paneId: string, dir: "row" | "column") {
const s = useTabs.getState();
const srcPane = s.panes[paneId];
const srcSlot = srcPane?.active ? this.slots.get(srcPane.active) : undefined;
const r = splitPane(s, paneId, dir);
useTabs.setState(r.state, true);
const newTab = r.state.panes[r.paneId]?.active;
if (newTab) {
void this.ensureSlot(newTab, { from: srcSlot }).then((slot) => {
if (slot?.view) slot.focus();
else if (slot) slot.wantsFocus = true;
});
}
}
unsplit(paneId: string) {
this.apply((s) => removePane(s, paneId));
}
closeActiveTab() {
const s = useTabs.getState();
const pane = s.panes[s.activePane];
if (pane?.active) this.closeTab(pane.id, pane.active);
else if (paneIds(s).length > 1) this.unsplit(s.activePane);
}
closeEverything() {
this.apply((s) => {
let next = s;
for (const id of paneIds(s)) if (next.panes[id]) next = closeAll(next, id);
return { ...next, closed: [] };
});
}
private onFilesRemoved(removed: string[]) {
if (!removed.length) return;
for (const path of removed) {
const isUnder = (k: string) => k === fileKey(path) || k.startsWith(fileKey(path) + "/");
const s = useTabs.getState();
const keys = new Set<string>();
for (const pane of Object.values(s.panes))
for (const t of pane.tabs) if (isUnder(t.input.key)) keys.add(t.input.key);
for (const key of keys) {
const model = this.manager.get(key.slice(5));
if (model?.getMeta().dirty) continue;
this.apply((st) => closeKey(st, key));
this.nav.removePath(key.slice(5));
this.edits.removePath(key.slice(5));
}
}
}
// --- service helpers ------------------------------------------------------------------
focus() {
const tab = this.activeTabOf();
if (!tab) {
document.querySelector<HTMLElement>("[data-editor-area]")?.focus({ preventScroll: true });
return;
}
const slot = this.slots.get(tab.id);
if (slot?.view) {
slot.focus();
return;
}
if (slot) {
slot.wantsFocus = true;
return;
}
const pane = document.querySelector<HTMLElement>(`[data-pane-id="${useTabs.getState().activePane}"]`);
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>();
for (const id of paneIds(s)) for (const t of s.panes[id]?.tabs ?? []) if (t.input.path) out.add(t.input.path);
return [...out];
}
closePath(path: string) {
this.apply((s) => {
const keys = new Set<string>();
for (const pane of Object.values(s.panes)) for (const t of pane.tabs) if (t.input.path === path) keys.add(t.input.key);
let next = s;
for (const key of keys) next = closeKey(next, key);
return next;
});
}
closeInput(key: string) {
this.apply((s) => closeKey(s, key));
}
activePath(): string | null {
return this.activeSlot?.model.path ?? this.activeTabOf()?.input.path ?? null;
}
activeCursor() {
const slot = this.activeSlot;
if (!slot) return null;
return { doc: slot.model.doc, path: slot.model.path, ...slot.caret() };
}
saveAll() {
return this.manager.saveAll("explicit");
}
/** Context menu of a tab (IDEA's EditorTabPopupMenu). */
tabMenu(paneId: string, tab: Tab): MenuItem[] {
const path = tab.input.kind === "virtual" ? undefined : tab.input.path;
const tabs = useTabs.getState().panes[paneId]?.tabs ?? [];
const index = tabs.findIndex((t) => t.id === tab.id);
const split = paneIds(useTabs.getState()).length > 1;
const project = useProject.getState().project;
const inProject = !!path && !!project && (path === project.root || path.startsWith(`${project.root}/`));
const activateThen = (run: () => void) => () => {
this.activate(paneId, tab.id, false);
run();
};
return [
{ label: "Close", shortcut: shortcutLabel("editor.closeTab"), run: () => this.closeTab(paneId, tab.id) },
{ label: "Close Other Tabs", disabled: tabs.length < 2, run: () => this.closeOthers(paneId, tab.id) },
{ label: "Close Tabs to the Left", disabled: index <= 0, run: () => this.closeToLeft(paneId, tab.id) },
{
label: "Close Tabs to the Right",
disabled: index < 0 || index >= tabs.length - 1,
run: () => this.closeToRight(paneId, tab.id),
},
{ label: "Close All Tabs", run: () => this.closeAll(paneId) },
{ label: "Close Unmodified Tabs", run: () => this.closeUnmodified(paneId) },
...(tabs.some((t) => t.pinned)
? ([{ label: "Close All but Pinned", run: () => this.closeAll(paneId, true) }] as MenuItem[])
: []),
{ kind: "separator" },
{ label: tab.pinned ? "Unpin Tab" : "Pin Tab", icon: "pin", run: () => this.togglePin(paneId, tab.id) },
{ kind: "separator" },
{ label: "Split and Move Right", run: () => this.moveTabToSplit(paneId, tab.id, paneId, "right") },
{ label: "Split and Move Down", run: () => this.moveTabToSplit(paneId, tab.id, paneId, "bottom") },
{
label: "Split Right",
icon: "split",
shortcut: shortcutLabel("editor.splitRight"),
run: activateThen(() => this.split(paneId, "row")),
},
{
label: "Split Down",
icon: "split-horizontal",
shortcut: shortcutLabel("editor.splitDown"),
run: activateThen(() => this.split(paneId, "column")),
},
...(split
? ([
{ label: "Move to Opposite Group", run: () => this.moveToOpposite(paneId, tab.id) },
{ label: "Unsplit", run: () => this.unsplit(paneId) },
] as MenuItem[])
: []),
{ kind: "separator" },
...(path
? ([
// Contributed groups (Run / Debug '…', More Run/Debug ›), each followed by a separator.
...menuGroupItems("editorTab", { paths: [path], text: this.slots.get(tab.id)?.view?.state.doc.toString() }),
openInMenu(path),
{ kind: "separator" },
localHistoryMenu(path),
...(inProject ? [gitMenu(path)] : []),
{ kind: "separator" },
{
label: "Copy Path/Reference…",
submenu: [
{ kind: "action", action: "project.copyAbsolutePath", arg: path, label: "Absolute Path" },
{ kind: "action", action: "project.copyFileName", arg: path, label: "File Name" },
...(inProject
? ([{ kind: "action", action: "project.copyRelativePath", arg: path, label: "Path From Content Root" }] as MenuItem[])
: []),
],
},
{ kind: "action", action: "project.rename", arg: path, label: "Rename File…" },
{ kind: "separator" },
] as MenuItem[])
: []),
{ kind: "action", action: "file.settings", arg: "editor.general.editorTabs", label: "Configure Editor Tabs…" },
];
}
}
/** Open In › for a file: Project View, Finder / Explorer / Files, Terminal. */
function openInMenu(path: string): MenuItem {
return {
label: "Open In",
submenu: [
{ kind: "action", action: "navigate.selectIn", arg: { path }, label: "Project View" },
{ kind: "action", action: "project.revealInFinder", arg: path, label: revealLabel() },
{ kind: "action", action: "project.openInTerminal", arg: path, label: "Terminal" },
{ kind: "separator" },
...menuGroupItems("openIn", { paths: [path] }),
],
};
}
function localHistoryMenu(path: string): MenuItem {
return {
label: "Local History",
submenu: [
{ kind: "action", action: "vcs.localHistory", arg: { path }, label: "Show History" },
{ kind: "action", action: "localHistory.putLabel", label: "Put Label…" },
],
};
}
/** Git › for one file (editor and editor tab menus). */
function gitMenu(path: string): MenuItem {
return {
label: "Git",
submenu: [
{ kind: "action", action: "vcs.commit", label: "Commit File…" },
{ kind: "action", action: "vcs.addToVcs", arg: [path], label: "Add" },
{ kind: "separator" },
{ kind: "action", action: "vcs.annotate", arg: path, label: "Annotate with Git Blame" },
{ kind: "action", action: "vcs.showDiff", arg: [path], label: "Show Diff" },
{ kind: "action", action: "vcs.compareWithBranch", arg: { path }, label: "Compare with Branch…" },
{ kind: "action", action: "vcs.showHistory", arg: [path], label: "Show History" },
{ kind: "action", action: "vcs.rollback", arg: [path], label: "Rollback…" },
{ kind: "separator" },
{ kind: "action", action: "vcs.push", label: "Push…" },
{ kind: "action", action: "vcs.update", label: "Pull…" },
{ kind: "action", action: "vcs.fetch", label: "Fetch" },
{ kind: "separator" },
{ kind: "action", action: "vcs.branches", label: "Branches…" },
],
};
}
function sameStatus(a: EditorStatus, b: EditorStatus): boolean {
return (
a.path === b.path &&
a.doc === b.doc &&
a.offset === b.offset &&
a.line === b.line &&
a.col === b.col &&
a.selectedChars === b.selectedChars &&
a.selectedLines === b.selectedLines &&
a.carets === b.carets
);
}
/** Status bar caret / selection info derived from the slot's latest state. */
export function computeStatus(slot: EditorSlot): EditorStatus {
const state = slot.currentState();
const main = state.selection.main;
const line = state.doc.lineAt(main.head);
let chars = 0;
let lines = 0;
for (const r of state.selection.ranges) {
chars += r.to - r.from;
if (!r.empty) lines += state.doc.lineAt(r.to).number - state.doc.lineAt(r.from).number;
}
const status: EditorStatus = {
path: slot.model.path,
doc: slot.model.doc,
line: line.number - 1,
col: main.head - line.from,
offset: main.head,
selectedChars: chars,
selectedLines: lines,
carets: state.selection.ranges.length,
};
return status;
}
let controller: EditorController | null = null;
export function editorController(): EditorController {
return (controller ??= new EditorController());
}
/** Test hook. */
export function _setEditorController(c: EditorController | null) {
controller = c;
}
/** "Finder" / "Explorer" / "Files", like IDEA's Open In submenu. */
function revealLabel(): string {
const os = usePlatform.getState().os;
return os === "macos" ? "Finder" : os === "windows" ? "Explorer" : "Files";
}