mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
* feat: Second pass aligning interactions with IntelliJ IDEA Result tool windows - Next/Previous Occurrence (⌥⌘↓/↑) across Find, Usages, Problems, TODO, Inspection and Hierarchy, with an "N of M" status hint - Find: reuse the unpinned tab, Pin Tab, source preview, group by file or directory, exclude moves to the next result, Rerun - Usages: tabs with pinning, exclude and preview - Problems, TODO, Inspection: no autoscroll by default, severity-first sorting, context menu with quick fixes and copy description - Trees: F4/⌘↓ jump to source; speed search Home/End and arrow handling Find in Files, bookmarks, navigation - Replace mode: Enter replaces the selection, ⇧⌥Enter replaces all, multi-select - Pin Window toggle, highlighted read-only preview, search/replace history (⌥↓), Alt mnemonics, search context filter (comments / string literals) - Mnemonic bookmarks (⌥F3, ⌃⇧digit, ⌃digit), ⌘F3 bookmarks popup, descriptions, ⌘-click gutter toggles a bookmark - Select In popup (⌥F1); Recent Locations double-click and Delete; Search Everywhere remembers the query per tab Code insight and refactoring - Extract/Introduce continue with in-place rename and name suggestions; expression and occurrence choosers with live highlighting - Safe Delete confirms first; numbered Refactor This - Quick Documentation history, in-popup links and a Documentation tool window; Quick Definition switches between targets and shows the whole declaration - Goto chooser lists symbol names and can pin to the Find window - Parameter Info Tab/⇧Tab, intention preview, Reformat dialog and summary, inlay hint ⌘-click and context menu - Live templates: Enter moves to the next field, two-step Esc, ⌘J as a lookup, language surrounders with mnemonics - File Structure popup as a filterable tree; Show Usages filter and preview; call hierarchy jumps to call sites Editor - Paste re-indents each line by default; Move Statement moves whole statements in and out of blocks and re-indents - Type over closing quotes of existing strings; ⌘D/⌘⌫ cover folded regions; ⌥⌫ deletes the preceding word - ⌃G / ⌃⌘G whole-word rules and wrap hint; double-modifier caret cloning - Visual-column block selection, nested Collapse All, recursive folding, fold selection toggle, Go to Matching Brace, gutter drag selects lines, sticky line and error stripe clicks, Insert toggles overwrite mode Run, VCS, workbench - Gutter icons show the last test result; Modify Run Configuration; test tree hides passed tests, context menu and assertion diff - Stack frames resolve by fully qualified class name; context run chooser - Inline Smart Step Into - Git Log multi-select filters, "me" and date range; favourite branches, Fetch and tags in the branch popup; stash Keep index and Unstash options - Terminal tab titles follow the shell; Distraction Free persists; gear menu; sticky balloons move to the notifications tool window after a while * fix(vcs): Label restored local changes as yours after Smart Checkout; name the branch checked out Conflicts from restoring stashed changes show the user's uncommitted changes on the left like other operations, and the checkout notification names the new branch instead of the previous one. * fix: Close the merge editor on Apply; confirm before terminating a running Run tab Like IDEA, applying a merge result closes its tab, and closing a Run tab whose process still runs asks before terminating it (with a Settings toggle and "Do not ask again"). * fix(terminal): Don't start a new session in the background after closing the last tab The hidden Terminal window stays mounted, so it reopened a session as soon as the last tab closed. It now opens the Local tab only once the window is shown again, as IDEA does. * fix(vcs): Start the merge result from a three-way merge; close the editor on Apply The result pane now begins with non-conflicting changes merged and each conflict showing the base text, resolved from the side panes, instead of raw conflict markers. Apply now reports success, so the tab closes. fix(search): Match File Structure filter on word starts * fix: Limit Smart Step Into to the paused line; drop usages hints on callbacks; show full intention titles Adapters that list every remaining call in the function (debugpy) made Smart Step Into fall back to a chooser that offered calls from later lines. Targets are now narrowed to the calls on the paused line, so they are picked in place. Anonymous functions and callbacks no longer get a 'no usages' hint, and intention titles are no longer cut short when there is room for them. * fix: Undo / Redo from the Edit menu and Find Action use the editor's history They ran the webview's native undo, which misses programmatic edits such as templates, completion and refactorings, so nothing happened in the editor.
1202 lines
44 KiB
TypeScript
1202 lines
44 KiB
TypeScript
/**
|
||
* 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 { useProject } from "@/platform/project";
|
||
import { editorPrefs } from "@/platform/editorSettings";
|
||
import {
|
||
_emitEditorEvent,
|
||
_provideNavigationHistory,
|
||
_provideOpenEditorPaths,
|
||
_setActiveEditor,
|
||
type NavigationLocation,
|
||
} from "@/editor/api";
|
||
import {
|
||
getFileDecoration,
|
||
menuGroupItems,
|
||
notifications,
|
||
resolveFileEditor,
|
||
type EditorInput,
|
||
type OpenOptions,
|
||
} from "@/workbench/contributions";
|
||
import { useDistractionFree } from "@/workbench/distractionFree";
|
||
import { 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,
|
||
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;
|
||
}
|
||
|
||
function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: string } } {
|
||
return !!tab && isTextInput(tab.input);
|
||
}
|
||
|
||
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>();
|
||
|
||
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.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();
|
||
}
|
||
storeSession(root, serializeSession(useTabs.getState(), files));
|
||
}
|
||
|
||
/** 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 = 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 || 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;
|
||
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;
|
||
}
|
||
/** 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 });
|
||
}
|
||
|
||
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";
|
||
}
|