/** * 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 { pasteEditorClipboard } from "./cm/clipboard"; import { EditorSelection } from "@codemirror/state"; import { create } from "zustand"; import { setContext } from "@/platform/context"; import { shortcutLabel } from "@/platform/keymap"; import { isSameOrInside } from "@/platform/paths"; 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, textEditorWrapperFor, useContributions, useStandaloneEditor, useTextEditorWrappers, type EditorCloseSession, type EditorInput, type OpenOptions, type FileEditorResolution, type OpenEditorTab, type TextEditorWrapperContribution, } from "@/workbench/contributions"; import { useDistractionFree } from "@/workbench/distractionFree"; import { restoreMaximizedToolWindow } from "@/workbench/toolwindows/store"; import { ask } from "@/workbench/messages"; import { t } from "@/platform/i18n"; import { call, commands } from "@/platform/ipc"; import { isRemoteWindow } from "@/platform/remote"; import { fileIconName, showContextMenu, type MenuItem } from "@/ui"; import { fileManagerName } from "@/platform/fileManager"; import { activateTab, changeSplitOrientation, closeAll, closeKey, closeOthers, closeTab, closeToLeft, closeToRight, closeUnmodified, cycleTab, enforceTabLimit, findTab, initialTabsState, mergeSessionTabs, moveTab, moveTabToNewSplit, neighbourPane, openKeys, openTab, paneIds, paneOf, promoteTab, removePane, reopenClosed, setActivePane, splitAtEdge, sortTabs, splitPane, tabOptions, togglePin, unsplitAll, type SplitEdge, type Tab, type TabsState, } from "./model/tabs"; import { DocumentManager, useDocMeta } from "./model/document"; import { installDiskSync } from "./diskSync"; import { closedDocuments, combinedError, customDocuments, documentDirty, documentLifecycle, failures, inputDirty, type CustomDocument, } from "./model/customDocuments"; 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 { isClassFile } from "@/platform/classFiles"; 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, ): 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(() => initialTabsState()); /** Session key of the LightEdit window: its tabs reopen the next time a window switches to LightEdit. */ export const LIGHT_EDIT_SESSION = ""; 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>(() => ({})); /** Per-tab find bar state (open / replace mode), like IDEA's per-editor search session. */ export const useFindBars = create>(() => ({})); 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(); 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["editorKinds"]; interface OpenRequest { project: number; admission: number; sequence: number; activation: number; pane: string; } /** 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 ?? ""), input.path ?? null); 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(); private pending = new Map>(); readonly nav = new NavigationHistory(); readonly edits = new EditHistory(); private navSuppress = 0; activeSlot: EditorSlot | null = null; private statusScheduled = false; private disposers: Array<() => void> = []; /** Project root (or {@link LIGHT_EDIT_SESSION}) whose editor session is being tracked (null while switching). */ private sessionRoot: string | null = null; private sessionTimer: ReturnType | undefined; /** Restored caret / scroll per path, applied when the file's first slot is created. */ private restoredViews = new Map(); /** Tabs created by session restore; dropped silently if their file is gone. */ private restoredTabs = new Set(); /** When each input key was last the active tab (a counter, across panes). */ private lastActive = new Map(); private activeClock = 0; /** Serializes close decisions without retaining a stale tabs snapshot. */ private removalTail: Promise = Promise.resolve(); private closingDocuments = new Map>(); private preparedDocuments = new Map(); private closeSession: EditorCloseSession | null = null; private closeCommitted = false; private projectGeneration = 0; private replacingProject = false; private admissionGeneration = 0; private openSequence = 0; private selectionRequests = new Map(); private activationGeneration = 0; private admitting = false; /** Tabs restored from a saved session: not a user's activation, so a pending open still activates its tab. */ private restoring = false; private closeRequests = 0; private projectTransition: Promise = Promise.resolve(); 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, previous) => { const open = openKeys(s); const closed = new Map(); for (const pane of Object.values(previous.panes)) for (const tab of pane.tabs) if (!open.has(tab.input.key)) closed.set(tab.input.key, tab.input); if (!this.replacingProject) for (const input of closed.values()) useContributions.getState().editorKinds.get(input.kind)?.onClose?.(input); const prev = prevTabs; prevTabs = s; if ( !this.admitting && !this.restoring && (s.activePane !== prev.activePane || s.panes[s.activePane]?.active !== prev.panes[prev.activePane]?.active) ) ++this.activationGeneration; this.stampActive(s); this.onTabsChanged(prev, s); const kinds = useContributions.getState().editorKinds; const standalone = Object.values(s.panes) .flatMap((pane) => pane.tabs) .find((tab) => kinds.get(tab.input.kind)?.standalone); useStandaloneEditor.setState({ title: standalone ? tabTitle(standalone.input, kinds) : null }); }), ); tabOptions.activateOnClose = editorPrefs().tabs.activateOnClose as typeof tabOptions.activateOnClose; let prevSettings = useProject.getState().settings; let prevRoot = useProject.getState().project?.root ?? null; let prevLight = useProject.getState().lightEdit; this.disposers.push( useProject.subscribe((s, previous) => { 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; // Entering or leaving LightEdit replaces the backend's documents too. if (root !== prevRoot || s.lightEdit !== prevLight || (s.opening && s.project !== previous.project)) { prevRoot = root; prevLight = s.lightEdit; this.saveSession(); if ( root && previous.project && !previous.project.workspace && s.project?.workspace?.projects[0] === previous.project.root ) { const session = loadSession(previous.project.root); if (session) storeSession(root, session); } this.sessionRoot = null; const generation = ++this.projectGeneration; const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds); // The guard decided already. Disposal must never prompt or autosave // custom documents after the backend has replaced their project. this.replacingProject = true; try { useTabs.setState(initialTabsState(), true); } finally { this.replacingProject = false; } this.closeSession = null; this.closeCommitted = false; this.preparedDocuments.clear(); this.nav.clear(); this.edits.clear(); this.restoredTabs.clear(); this.restoredViews.clear(); this.projectTransition = this.releaseDocuments(documents.values()) .catch((e) => this.documentError(e)) .then(() => { if (generation !== this.projectGeneration) return; if (root) return this.restoreSession(root); else if (s.lightEdit) return this.restoreSession(LIGHT_EDIT_SESSION); }); } else if (previous.opening && !s.opening && this.closeCommitted) { // A failed project open returns to the same project. this.closeSession?.cancel(); } }), ); // Gutter, centred column and sticky lines follow Distraction Free Mode. this.disposers.push(useDistractionFree.subscribe(() => this.manager.applySettings())); this.disposers.push( useTextEditorWrappers.subscribe(({ wrappers }) => { this.upgradeWrappedImageTabs(wrappers); }), ); if (prevRoot) this.projectTransition = 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() && !this.closeSession) { void this.manager.saveAll("auto"); void this.saveCustomDocuments(true).catch((e) => this.documentError(e)); } }; if (this.manager.backend.onApplicationDeactivated) this.disposers.push(this.manager.backend.onApplicationDeactivated(onWindowBlur)); const onPageHide = () => this.saveSession(); if (typeof window !== "undefined") { if (this.manager.backend.syncFromDisk) this.disposers.push( installDiskSync( () => this.manager.backend.syncFromDisk!(), () => !this.closeSession, ), ); 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; ++this.projectGeneration; clearTimeout(this.sessionTimer); const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds); this.disposers.splice(0).forEach((d) => d()); this.closeSession = null; this.closeCommitted = false; this.preparedDocuments.clear(); for (const slot of this.slots.values()) slot.dispose(); this.slots.clear(); this.selectionRequests.clear(); this.manager.stop(); useTabs.setState(initialTabsState(), true); useStandaloneEditor.setState({ title: null }); void this.releaseDocuments(documents.values()).catch((e) => this.documentError(e)); } autoSave(): boolean { return this.manager.settings()?.files?.autoSave !== false; } // --- tab store reactions ------------------------------------------------- private onTabsChanged(prev: TabsState, next: TabsState) { const live = new Set(); for (const p of Object.values(next.panes)) for (const t of p.tabs) live.add(t.id); for (const id of this.selectionRequests.keys()) if (!live.has(id)) this.selectionRequests.delete(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(); } } const bars = Object.entries(useFindBars.getState()).filter(([id, bar]) => bar && !live.has(id)); if (bars.length) useFindBars.setState(Object.fromEntries(bars.map(([id]) => [id, undefined]))); for (const id of Object.keys(useTabErrors.getState())) if (!live.has(id)) useTabErrors.setState((s) => { const n = { ...s }; delete n[id]; return n; }, true); // Save only a document that remains open. A discarded/closing document must // never be saved by the tab removal that follows its close decision. if (this.autoSave() && !this.closeSession) { const saved = new Set(); const open = openKeys(next); 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(() => {}); const input = pane.tabs.find((t) => t.id === pane.active)?.input; if (input && open.has(input.key) && !saved.has(input.key) && !this.preparedDocuments.has(input.key)) { saved.add(input.key); const lifecycle = documentLifecycle(input, useContributions.getState().editorKinds); if (lifecycle && documentDirty({ input, lifecycle })) void lifecycle.save(input).catch((e) => this.documentError(e)); } } } } // 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 upgradeWrappedImageTabs(wrappers: ReadonlyMap) { const s = useTabs.getState(); let changed = false; const panes: TabsState["panes"] = {}; const toEnsure: string[] = []; for (const [paneId, pane] of Object.entries(s.panes)) { let paneChanged = false; const tabs = pane.tabs.map((tab) => { const path = tab.input.path; if ( tab.input.kind === "image" && path && textEditorWrapperFor(wrappers, path) && !pane.tabs.some((other) => other.id !== tab.id && other.input.key === fileKey(path)) ) { paneChanged = true; changed = true; if (pane.active === tab.id) toEnsure.push(tab.id); return { ...tab, input: fileInput(path) }; } return tab; }); panes[paneId] = paneChanged ? { ...pane, tabs } : pane; } if (!changed) return; useTabs.setState({ ...s, panes }, true); for (const tabId of toEnsure) void this.ensureSlot(tabId); } 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 = 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). */ async restoreSession(root: string) { this.sessionRoot = root; const session = loadSession(root); if (!session) return; const state = restoreTabsState(session, fileKey); const kinds = useContributions.getState().editorKinds; for (const pane of Object.values(state.panes)) pane.tabs = pane.tabs.map((tab) => (documentLifecycle(tab.input, kinds) ? { ...tab, preview: false } : tab)); const current = useTabs.getState(); const liveKeys = openKeys(current); this.restoredViews = new Map(Object.entries(session.files).filter(([path]) => !liveKeys.has(fileKey(path)))); this.restoredTabs = new Set(Object.values(state.panes).flatMap((p) => p.tabs.map((t) => t.id))); this.restoring = true; try { // A restore landing after tabs were already opened (e.g. a launched file // admitted while the transition was still in flight) must not wipe them. useTabs.setState(mergeSessionTabs(current, state), true); } finally { this.restoring = false; } if (liveKeys.size) for (const paneId of paneIds(useTabs.getState())) await this.enforcePaneTabLimit(paneId, undefined, liveKeys); const next = useTabs.getState(); for (const pane of Object.values(next.panes)) if (pane.active) void this.ensureSlot(pane.active); if (liveKeys.size) { const active = this.activeTabOf(next); if (active) { await this.ensureSlot(active.id); if (this.activeTabOf()?.id === active.id) this.focus(); } } } 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 { 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(() => { if (!slot.disposed && this.activeTabOf()?.id === slot.tabId) 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()) return; const save = () => void slot.model.save("auto").catch(() => {}); if (typeof document === "undefined") return save(); // A popup (intentions, completion, …) belongs to the editor: saving under it would change // the text (save cleanup) its items were computed for. Save once focus leaves it elsewhere. setTimeout(() => { if (slot.view?.hasFocus) return; if (!inPopup(document.activeElement)) return save(); const onFocusIn = (e: FocusEvent) => { if (inPopup(e.target)) return; document.removeEventListener("focusin", onFocusIn); if (!slot.view?.hasFocus) save(); }; document.addEventListener("focusin", onFocusIn); }); } 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 { if (useFindBars.getState()[slot.tabId]) { useFindBars.setState({ [slot.tabId]: 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 pasteEditorClipboard(view), }, { kind: "separator" }, { label: "Copy / Paste Special", submenu: [ { kind: "action", action: "editor.copyPathReference" }, { kind: "action", action: "editor.copyReference" }, { kind: "action", action: "code.pasteAsPlainText" }, { 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: "action", action: "refactor.pullMembersUp" }, { kind: "action", action: "refactor.pushMembersDown" }, { 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.compareWith", arg: slot.model.path, label: "Compare With…", }, { 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("editorTextFocus", !!slot?.view?.hasFocus); 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 --------------------------------------------------------------------------- private openingGeneration(o: OpenOptions): OpenRequest { const s = useTabs.getState(); const generation = { project: this.projectGeneration, admission: this.admissionGeneration, sequence: ++this.openSequence, activation: this.activationGeneration, pane: o.pane && s.panes[o.pane] ? o.pane : s.activePane, }; this.checkOpening(generation); return generation; } /** * A decision to leave that was authorized, but whose project change or window close never * came (the window stayed with its project): released, so the window can open files and * be left again instead of refusing both until it is restarted. */ private releaseStaleClose() { if (this.closeSession && this.closeCommitted && !useProject.getState().opening) this.closeSession.cancel(); } private checkOpening(generation: { project: number; admission: number }) { if (generation.project !== this.projectGeneration) throw new Error("The project changed while opening the editor"); this.releaseStaleClose(); if (generation.admission !== this.admissionGeneration || this.closeSession || this.closeRequests) throw new Error("The project is closing"); } private isCurrentOpening(generation: OpenRequest) { return ( generation.project === this.projectGeneration && generation.admission === this.admissionGeneration && generation.sequence === this.openSequence && generation.activation === this.activationGeneration ); } async openFile(path: string, o: OpenOptions = {}): Promise { return this.openFileRequest(path, o, this.openingGeneration(o)); } private async openFileRequest(path: string, o: OpenOptions, generation: OpenRequest): Promise { if (isVirtualUri(path)) return this.openVirtualRequest(path, o, generation); if (isClassFile(path)) { await this.projectTransition; this.checkOpening(generation); try { const target = await call(commands.lspResolveClassFile(path)); this.checkOpening(generation); return this.openFileRequest(target, o, generation); } catch (e) { notifications().notify({ level: "warning", title: "Cannot open library class", body: String(e), group: "lsp" }); return; } } await this.projectTransition; this.checkOpening(generation); // 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) { try { await this.admitInput(viewer.input, o, generation, viewer); } finally { await viewer.rollback(); } return; } } await this.admitInput(fileInput(path), o, generation); } /** Opens the library class (or other server-provided document) behind `uri` read-only. */ async openVirtual(uri: string, o: OpenOptions = {}) { return this.openVirtualRequest(uri, o, this.openingGeneration(o)); } private async openVirtualRequest(uri: string, o: OpenOptions, generation: OpenRequest) { await this.projectTransition; this.checkOpening(generation); 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.admitInput(virtualInput(vd), o, generation); } async openInput(input: EditorInput, o: OpenOptions = {}) { if (input.path && isClassFile(input.path)) return this.openFile(input.path, o); return this.admitInput(input, o, this.openingGeneration(o)); } private async admitInput( input: EditorInput, o: OpenOptions, generation: OpenRequest, resolution?: FileEditorResolution, ) { await this.projectTransition; this.checkOpening(generation); await this.closingDocuments.get(input.key); this.checkOpening(generation); this.navSuppress++; try { let s = useTabs.getState(); const activate = this.isCurrentOpening(generation); if (activate) restoreMaximizedToolWindow(); const preview = !!o.preview && editorPrefs().tabs.previewTab && !documentLifecycle(input, useContributions.getState().editorKinds); if (!activate && preview) return; let paneId = s.panes[generation.pane] ? generation.pane : activate ? s.activePane : null; if (!paneId) return; 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: [] } } }; if (!activate) s = { ...s, activePane: useTabs.getState().activePane }; } } const found = findTab(s, input.key, paneId); let tabId: string; if (found && (!(o.side || o.pane) || found.paneId === paneId)) { paneId = found.paneId; if (activate) s = activateTab(setActivePane(s, paneId), paneId, found.tab.id); if (!o.preview || documentLifecycle(input, useContributions.getState().editorKinds)) s = promoteTab(s, paneId, found.tab.id); tabId = found.tab.id; if (activate && found.tab.input.data !== input.data && input.data !== undefined) s = openTab(s, paneId, input, { preview: o.preview && !documentLifecycle(input, useContributions.getState().editorKinds), activate, }).state; } else { const tabs = editorPrefs().tabs; const r = openTab(s, paneId, input, { preview, activate, preservePreview: !activate, ...(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)); } if (o.selection) this.selectionRequests.set(tabId, Math.max(generation.sequence, this.selectionRequests.get(tabId) ?? 0)); this.admitting = true; try { resolution?.admit(); useTabs.setState(s, true); } finally { this.admitting = false; } const canFocus = () => this.isCurrentOpening(generation) && this.activeTabOf()?.id === tabId; await this.enforcePaneTabLimit(paneId, activate ? tabId : (useTabs.getState().panes[paneId]?.active ?? tabId)); if (!isTextInput(input)) { if (o.focus !== false) requestAnimationFrame(() => { if (canFocus()) this.focus(); }); return; } const slot = await this.ensureSlot(tabId); if (!slot) return; if ( o.selection && this.selectionRequests.get(tabId) === generation.sequence && generation.activation === this.activationGeneration ) { 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 && canFocus()) { if (slot.view) slot.focus(); else slot.wantsFocus = true; } } finally { this.navSuppress--; if (this.navSuppress === 0 && this.activeSlot) this.recordNav(this.activeSlot, true); } } private enforcePaneTabLimit(paneId: string, keep?: string, liveKeys?: ReadonlySet) { const prefs = editorPrefs().tabs; const protect = (tab: Tab) => !!liveKeys?.has(tab.input.key) || inputDirty(tab.input, useContributions.getState().editorKinds) === true; return this.removeGuarded( (current) => current.panes[paneId] ? enforceTabLimit( current, paneId, prefs.tabLimit, prefs.closingPolicy, (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) ?? !!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty), keep ?? current.panes[paneId]?.active ?? undefined, protect, ) : current, { protect }, ); } activate(paneId: string, tabId: string, focus = true) { const restored = restoreMaximizedToolWindow(); ++this.activationGeneration; useTabs.setState((s) => activateTab(setActivePane(s, paneId), paneId, tabId), true); if (focus) { if (restored) { requestAnimationFrame(() => this.focus()); return; } const slot = this.slots.get(tabId); if (slot?.view) slot.focus(); else if (slot) slot.wantsFocus = true; else void this.ensureSlot(tabId).then((sl) => { if (sl && this.activeTabOf()?.id === tabId) { if (sl.view) sl.focus(); else sl.wantsFocus = true; } }); } } /** Window › Editor Tabs › Goto Next / Previous Splitter: the active tab of the next split pane, wrapping. */ focusSplitter(dir: 1 | -1) { const s = useTabs.getState(); const ids = paneIds(s).filter((id) => s.panes[id]?.active); if (ids.length < 2) return; const next = ids[(ids.indexOf(s.activePane) + dir + ids.length) % ids.length]!; this.activate(next, s.panes[next]!.active!); } // --- tab commands ----------------------------------------------------------------------------- private apply(f: (s: TabsState) => TabsState) { useTabs.setState((s) => f(s), true); } private documentError(error: unknown) { notifications().notify({ level: "error", title: t("app.unsaved.saveFailed"), body: error instanceof Error ? error.message : String(error), }); } private enqueueRemoval(run: () => Promise): Promise { const result = this.removalTail.then(run); this.removalTail = result.catch(() => {}); return result; } private releaseDocuments(documents: Iterable): Promise { const pending = [...documents].map((d) => { const key = d.input.key; const previous = this.closingDocuments.get(key) ?? Promise.resolve(); const close = previous.then(() => d.lifecycle.close(d.input)); this.closingDocuments.set(key, close); return close.finally(() => { if (this.closingDocuments.get(key) === close) this.closingDocuments.delete(key); }); }); return Promise.allSettled(pending).then((results) => { const errors = failures(results); if (errors.length) throw combinedError(errors); }); } /** Close exactly the originally selected tabs, evaluating references after every await. */ private removeGuarded( select: (s: TabsState) => TabsState, options: { protect?: (tab: Tab) => boolean; clearHistory?: boolean; beforeRemove?: () => Promise } = {}, ): Promise { const generation = this.projectGeneration; const initial = useTabs.getState(); const planned = select(initial); const remainingIds = new Set(Object.values(planned.panes).flatMap((p) => p.tabs.map((t) => t.id))); const targets = new Set( Object.values(initial.panes).flatMap((p) => p.tabs.filter((t) => !remainingIds.has(t.id)).map((t) => t.id)), ); const emptyPanes = paneIds(initial).filter((id) => !planned.panes[id] && !initial.panes[id]?.tabs.length); return this.enqueueRemoval(async () => { if (generation !== this.projectGeneration || this.closeSession) return false; const prepared: CustomDocument[] = []; const currentRemoval = (s: TabsState) => { let next = s; for (const [paneId, pane] of Object.entries(s.panes)) for (const tab of pane.tabs) if (targets.has(tab.id) && next.panes[paneId] && !options.protect?.(tab)) next = closeTab(next, paneId, tab.id); for (const id of emptyPanes) if (next.panes[id]?.tabs.length === 0 && paneIds(next).length > 1) next = removePane(next, id); return next; }; const stillLast = (d: CustomDocument) => { if (generation !== this.projectGeneration) return false; const current = useTabs.getState(); return openKeys(current).has(d.input.key) && !openKeys(currentRemoval(current)).has(d.input.key); }; try { const authorized = new Set(); const discards = new Map(); for (;;) { const current = useTabs.getState(); const d = closedDocuments(current, currentRemoval(current), useContributions.getState().editorKinds).find( (document) => !authorized.has(document.input.key), ); if (!d) { // A later Cancel/save failure must not discard earlier documents. const discard = [...discards.values()].find(stillLast); if (!discard) break; discards.delete(discard.input.key); await discard.lifecycle.discard(discard.input); // The await may have changed which documents lose their last tab. if (generation !== this.projectGeneration) return false; continue; } if (!prepared.some((document) => document.input.key === d.input.key)) { prepared.push(d); this.preparedDocuments.set(d.input.key, d); await d.lifecycle.prepareClose(d.input); } if (generation !== this.projectGeneration) return false; if (!stillLast(d)) continue; if (documentDirty(d)) { const decision = await ask({ title: t("app.unsaved.title"), body: t("app.unsaved.body", { count: 1, project: d.input.path ?? d.input.key }), buttons: [ { id: "discard", label: t("app.unsaved.discard") }, { id: "save", label: t("app.unsaved.save"), primary: true }, ], icon: "warning", }); if (!stillLast(d)) continue; if (decision.button === "save") await d.lifecycle.save(d.input); else if (decision.button === "discard") discards.set(d.input.key, d); else return false; } authorized.add(d.input.key); } if (options.beforeRemove && !(await options.beforeRemove())) return false; // Commit only the original target ids against the latest tab state. if (generation !== this.projectGeneration) return false; const before = useTabs.getState(); const next = currentRemoval(before); const closed = closedDocuments(before, next, useContributions.getState().editorKinds); const closedKeys = new Set(closed.map((d) => d.input.key)); for (const d of prepared) if (!closedKeys.has(d.input.key)) d.lifecycle.cancelClose(d.input); // Remove frozen markers only after the tab reaction, to prevent autosave. useTabs.setState(options.clearHistory ? { ...next, closed: [] } : next, true); for (const d of prepared) this.preparedDocuments.delete(d.input.key); prepared.length = 0; await this.releaseDocuments(closed); return true; } catch (e) { this.documentError(e); return false; } finally { for (const d of prepared) { if (this.preparedDocuments.get(d.input.key) === d) this.preparedDocuments.delete(d.input.key); if (generation === this.projectGeneration) d.lifecycle.cancelClose(d.input); } } }); } closeTab(paneId: string, tabId: string): Promise { return this.removeGuarded((s) => (s.panes[paneId] ? closeTab(s, paneId, tabId) : s)); } closeOthers(paneId: string, tabId: string): Promise { return this.removeGuarded((s) => (s.panes[paneId] ? closeOthers(s, paneId, tabId) : s), { protect: (tab) => tab.pinned, }); } closeAll(paneId: string, keepPinned = false): Promise { return this.removeGuarded((s) => (s.panes[paneId] ? closeAll(s, paneId, keepPinned) : s), { protect: (tab) => keepPinned && tab.pinned, }); } closeToRight(paneId: string, tabId: string): Promise { return this.removeGuarded((s) => (s.panes[paneId] ? closeToRight(s, paneId, tabId) : s), { protect: (tab) => tab.pinned, }); } closeToLeft(paneId: string, tabId: string): Promise { return this.removeGuarded((s) => (s.panes[paneId] ? closeToLeft(s, paneId, tabId) : s), { protect: (tab) => tab.pinned, }); } /** Keeps tabs with unsaved changes or a VCS status (IDEA: tabs shown as modified). */ closeUnmodified(paneId: string): Promise { const modified = (tab: Tab) => (inputDirty(tab.input, useContributions.getState().editorKinds) ?? false) || !!(tab.input.path && (useDocMeta.getState()[tab.input.path]?.dirty || getFileDecoration(tab.input.path)?.color)); return this.removeGuarded((s) => (s.panes[paneId] ? closeUnmodified(s, paneId, modified) : s), { protect: (tab) => tab.pinned || modified(tab), }); } closeReadOnly(paneId: string): Promise { const writable = (t: Tab) => !t.input.path || !useDocMeta.getState()[t.input.path]?.readOnly; return this.removeGuarded((s) => (s.panes[paneId] ? closeUnmodified(s, paneId, writable) : s), { protect: (tab) => tab.pinned || writable(tab), }); } /** 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, pin?: boolean) { this.apply((s) => moveTab(s, fromPane, tabId, toPane, index, pin)); } /** 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(); } async reopenClosed(): Promise { const generation = this.projectGeneration; await this.projectTransition; await this.removalTail; if (generation !== this.projectGeneration || this.closeSession) return; 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): Promise { return this.removeGuarded((s) => (s.panes[paneId] ? removePane(s, paneId) : s)); } async unsplitAll(paneId: string): Promise { const closed = await this.removeGuarded((s) => unsplitAll(s, paneId)); if (closed) { this.apply((s) => setActivePane(s, paneId)); this.focus(); } return closed; } changeSplitOrientation(paneId: string) { this.apply((s) => changeSplitOrientation(s, paneId)); } /** Save or resolve the dirty guard, open the window, then remove the original tab. */ async detachTab(paneId: string, tabId: string): Promise { const tab = useTabs.getState().panes[paneId]?.tabs.find((t) => t.id === tabId); const path = tab?.input.path; if ( !tab || !path || (tab.input.kind !== "file" && !documentLifecycle(tab.input, useContributions.getState().editorKinds)) ) return false; if (isRemoteWindow()) { notifications().notify({ level: "warning", title: t("app.window.desktopOnly") }); return false; } const project = useProject.getState().project; const inProject = !!project && (project.workspace?.projects ?? [project.root]).some((root) => isSameOrInside(path, root)); return this.removeGuarded((s) => (s.panes[paneId] ? closeTab(s, paneId, tabId) : s), { beforeRemove: async () => { if (!useTabs.getState().panes[paneId]?.tabs.some((t) => t.id === tabId)) return false; if (isTextTab(tab)) { const slot = await this.ensureSlot(tabId); if (!slot) return false; await slot.model.save("explicit"); } try { await call(commands.windowOpenFileInNewWindow(inProject ? project.root : null, path)); return true; } catch (e) { notifications().notify({ level: "error", title: t("app.window.openFailed", { name: basename(path) }), body: e instanceof Error ? e.message : String(e), key: `app.open:${path}`, }); return false; } }, }); } /** IDEA "Open in Opposite Group": the tab stays and a copy opens next door (or in a new split). */ openInOpposite(paneId: string, tabId: string) { const s = useTabs.getState(); const tab = s.panes[paneId]?.tabs.find((t) => t.id === tabId); if (!tab) return; const target = neighbourPane(s, paneId); if (!target) { this.activate(paneId, tabId, false); return this.split(paneId, "row"); } const r = openTab(s, target, tab.input); useTabs.setState(r.state, true); void this.ensureSlot(r.tabId, { from: this.slots.get(tabId) }).then((slot) => { if (slot?.view) slot.focus(); else if (slot) slot.wantsFocus = true; }); } closeActiveTab(): Promise { const s = useTabs.getState(); const pane = s.panes[s.activePane]; if (pane?.active) return this.closeTab(pane.id, pane.active); if (paneIds(s).length > 1) return this.unsplit(s.activePane); return Promise.resolve(true); } closeEverything(): Promise { return this.removeGuarded( (s) => { let next = s; for (const id of paneIds(s)) if (next.panes[id]) next = closeAll(next, id); return next; }, { clearHistory: true }, ); } private onFilesRemoved(removed: string[]) { if (!removed.length) return; const kinds = useContributions.getState().editorKinds; const inputs = new Map(); for (const pane of Object.values(useTabs.getState().panes)) for (const tab of pane.tabs) { const path = tab.input.path; if (path && removed.some((root) => path === root || path.startsWith(`${root}/`))) inputs.set(tab.input.key, tab.input); } for (const input of inputs.values()) { if (inputDirty(input, kinds) === true || (input.path && this.manager.get(input.path)?.getMeta().dirty)) continue; // A pending output/edit can make the document dirty before this queued // removal runs. Recheck then; external deletion never discards edits. void this.removeGuarded((s) => closeKey(s, input.key), { protect: (tab) => inputDirty(tab.input, kinds) === true || !!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty), }); if (input.path) { this.nav.removePath(input.path); this.edits.removePath(input.path); } } } // --- service helpers ------------------------------------------------------------------ focus() { if (restoreMaximizedToolWindow()) { requestAnimationFrame(() => this.focus()); return; } const tab = this.activeTabOf(); if (!tab) { document.querySelector("[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(`[data-pane-id="${useTabs.getState().activePane}"]`); pane?.querySelector("[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(); 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(); 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]; } /** Input of the tab `tabId` in pane `paneId` (dragging a tab between views), null if there is none. */ tabInput(paneId: string, tabId: string): EditorInput | null { return useTabs.getState().panes[paneId]?.tabs.find((t) => t.id === tabId)?.input ?? null; } closePath(path: string): Promise { return this.removeGuarded((s) => { const keys = new Set(); 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): Promise { return this.removeGuarded((s) => closeKey(s, key)); } activeInput(): EditorInput | null { return this.activeTabOf()?.input ?? null; } onActiveInputChange(fn: (input: EditorInput | null) => void): () => void { let prev = this.activeInput()?.key; return useTabs.subscribe(() => { const input = this.activeInput(); if (input?.key === prev) return; prev = input?.key; fn(input); }); } 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() }; } private async saveCustomDocuments(auto = false): Promise { const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds); const results = await Promise.allSettled( [...documents.values()] .filter((d) => (!auto || !this.preparedDocuments.has(d.input.key)) && documentDirty(d)) .map((d) => Promise.resolve().then(() => d.lifecycle.save(d.input))), ); const errors = failures(results); if (errors.length) throw combinedError(errors); } async saveAll(): Promise { const results = await Promise.allSettled([this.manager.saveAll("explicit"), this.saveCustomDocuments()]); const errors = failures(results); if (errors.length) throw combinedError(errors); } /** Hold every open custom document still until the project/window decision. */ prepareClose(): Promise { ++this.admissionGeneration; ++this.closeRequests; return this.enqueueRemoval(async () => { this.releaseStaleClose(); if (this.closeSession) throw new Error("An editor close decision is already pending"); const generation = this.projectGeneration; const documents = [...customDocuments(useTabs.getState(), useContributions.getState().editorKinds).values()]; const prepared: CustomDocument[] = []; let settled = false; const session: EditorCloseSession = { dirty: 0, discard: async () => { if (settled || this.closeSession !== session || generation !== this.projectGeneration) throw new Error("The editor close decision is no longer current"); const results = await Promise.allSettled( documents.filter(documentDirty).map((d) => Promise.resolve().then(() => d.lifecycle.discard(d.input))), ); const errors = failures(results); if (errors.length) throw combinedError(errors); }, cancel: () => { if (settled) return; settled = true; if (this.closeSession !== session || generation !== this.projectGeneration) return; this.closeSession = null; this.closeCommitted = false; for (const d of prepared) { this.preparedDocuments.delete(d.input.key); d.lifecycle.cancelClose(d.input); } }, commit: () => { if (settled || this.closeSession !== session || generation !== this.projectGeneration) throw new Error("The editor close decision is no longer current"); this.closeCommitted = true; }, }; this.closeSession = session; try { for (const d of documents) { prepared.push(d); this.preparedDocuments.set(d.input.key, d); await d.lifecycle.prepareClose(d.input); if (generation !== this.projectGeneration) throw new Error("The project changed during close"); if (this.closeSession !== session) { d.lifecycle.cancelClose(d.input); throw new Error("The editor close decision was cancelled"); } } session.dirty = documents.filter(documentDirty).length; return session; } catch (e) { session.cancel(); throw e; } }).finally(() => { --this.closeRequests; }); } cancelClose(): void { this.closeSession?.cancel(); } documentHistory(command: "undo" | "redo"): boolean { const input = this.activeTabOf()?.input; if (!input) return false; const lifecycle = documentLifecycle(input, useContributions.getState().editorKinds); const run = lifecycle?.[command]; if (!run) return false; void run.call(lifecycle, input).catch((e) => this.documentError(e)); return true; } /** 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: () => void this.closeTab(paneId, tab.id) }, { label: "Close Other Tabs", disabled: tabs.length < 2, run: () => void this.closeOthers(paneId, tab.id) }, { label: "Close Tabs to the Left", disabled: index <= 0, run: () => void this.closeToLeft(paneId, tab.id) }, { label: "Close Tabs to the Right", disabled: index < 0 || index >= tabs.length - 1, run: () => void this.closeToRight(paneId, tab.id), }, { label: "Close All Tabs", run: () => void this.closeAll(paneId) }, { label: "Close Unmodified Tabs", run: () => void this.closeUnmodified(paneId) }, ...(tabs.some((t) => t.pinned) ? ([{ label: "Close All but Pinned", run: () => void this.closeAll(paneId, true) }] as MenuItem[]) : []), ...(tabs.some((t) => !t.pinned && !!t.input.path && useDocMeta.getState()[t.input.path]?.readOnly) ? ([{ label: "Close All Read-Only Tabs", run: () => void this.closeReadOnly(paneId) }] as MenuItem[]) : []), { kind: "separator" }, { label: tab.pinned ? "Unpin Tab" : "Pin Tab", icon: "pin", run: () => this.togglePin(paneId, tab.id) }, ...(tab.preview ? ([{ label: "Keep Tab Open", run: () => this.keepTab(paneId, tab.id) }] as MenuItem[]) : []), { 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: "Open in Opposite Group", run: () => this.openInOpposite(paneId, tab.id) }, { label: "Change Splitter Orientation", run: () => this.changeSplitOrientation(paneId) }, { label: "Unsplit", run: () => void this.unsplit(paneId) }, { label: "Unsplit All", run: () => void this.unsplitAll(paneId) }, ] as MenuItem[]) : []), ...(tab.input.path && (tab.input.kind === "file" || documentLifecycle(tab.input, useContributions.getState().editorKinds)) ? ([{ label: "Open in New Window", run: () => void this.detachTab(paneId, tab.id) }] 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" }, { kind: "action", action: "diff.compareWith", arg: path, label: "Compare With…" }, { kind: "action", action: "diff.compareWithClipboard", arg: { path }, label: "Compare with Clipboard" }, { 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[]) : [ // Contributed groups for tabs without a file (e.g. "Open in Tool Window"). ...menuGroupItems("editorTab", { paths: [], input: tab.input }), ]), { 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: fileManagerName() }, { 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.showHistoryForSelection", label: "Show History for Selection" }, { 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()); } function inPopup(target: EventTarget | null): boolean { return target instanceof Element && !!target.closest("[data-popup]"); }