From e253928b521af1473a132ebaf9e491c863c6f07c Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:30:45 +0530 Subject: [PATCH 01/20] feat: add multi pane view --- src/cm/commandRegistry.js | 50 +++ src/handlers/editorFileTab.js | 4 + src/lib/commands.js | 44 +- src/lib/editorFile.js | 92 +++- src/lib/editorManager.js | 764 +++++++++++++++++++++++++++++----- src/lib/openFile.js | 18 +- src/styles/codemirror.scss | 124 ++++++ 7 files changed, 960 insertions(+), 136 deletions(-) diff --git a/src/cm/commandRegistry.js b/src/cm/commandRegistry.js index 43a0fb45e..41a08304b 100644 --- a/src/cm/commandRegistry.js +++ b/src/cm/commandRegistry.js @@ -213,6 +213,56 @@ function registerCoreCommands() { return true; }, }); + addCommand({ + name: "newPane", + description: "Create new editor pane", + readOnly: true, + requiresView: false, + run() { + acode.exec("new-pane"); + return true; + }, + }); + addCommand({ + name: "moveTabToNewPane", + description: "Move current tab to new pane", + readOnly: true, + requiresView: false, + run() { + acode.exec("move-tab-to-new-pane"); + return true; + }, + }); + addCommand({ + name: "closePane", + description: "Close active editor pane", + readOnly: true, + requiresView: false, + run() { + acode.exec("close-pane"); + return true; + }, + }); + addCommand({ + name: "focusNextPane", + description: "Focus next editor pane", + readOnly: true, + requiresView: false, + run() { + acode.exec("focus-next-pane"); + return true; + }, + }); + addCommand({ + name: "focusPreviousPane", + description: "Focus previous editor pane", + readOnly: true, + requiresView: false, + run() { + acode.exec("focus-previous-pane"); + return true; + }, + }); addCommand({ name: "closeAllTabs", description: "Close all tabs", diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index a0c2699a9..dae21b84f 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -432,6 +432,10 @@ function getClientPos(e) { * @param {HTMLElement} $parent */ function updateFileList($parent) { + if (typeof editorManager.updatePaneFileOrderFromTabs === "function") { + if (editorManager.updatePaneFileOrderFromTabs($parent)) return; + } + const pinnedCount = editorManager.files.filter((file) => file.pinned).length; const children = [...$parent.children]; const newFileList = []; diff --git a/src/lib/commands.js b/src/lib/commands.js index 2b2a1cc80..eebb9e4d2 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -59,8 +59,8 @@ export function canSaveFile(file = editorManager.activeFile) { } function getTabsRelativeToFile(side, referenceFile) { - const { files } = editorManager; const file = resolveReferenceFile(referenceFile); + const files = editorManager.getPaneFiles?.(file) || editorManager.files; const activeIndex = files.indexOf(file); if (activeIndex === -1) return []; @@ -163,7 +163,25 @@ export default { }); }, "close-current-tab"() { - editorManager.activeFile.remove(); + editorManager.activeFile?.remove(); + }, + "new-pane"() { + return editorManager.createPane?.(); + }, + "split-pane"() { + return editorManager.splitPane?.(); + }, + "close-pane"() { + return editorManager.closeActivePane?.(); + }, + "focus-next-pane"() { + return editorManager.focusNextPane?.(); + }, + "focus-previous-pane"() { + return editorManager.focusPreviousPane?.(); + }, + "move-tab-to-new-pane"() { + return editorManager.moveActiveFileToNewPane?.(); }, "toggle-pin-tab"(referenceFile) { resolveReferenceFile(referenceFile)?.togglePinned?.(); @@ -246,13 +264,18 @@ export default { }); }, "next-file"() { - const len = editorManager.files.length; - let fileIndex = editorManager.files.indexOf(editorManager.activeFile); + const files = + editorManager.getPaneFiles?.(editorManager.activeFile) || + editorManager.files; + const len = files.length; + let fileIndex = files.indexOf(editorManager.activeFile); + + if (!len || fileIndex === -1) return; if (fileIndex === len - 1) fileIndex = 0; else ++fileIndex; - editorManager.files[fileIndex].makeActive(); + files[fileIndex].makeActive(); }, async open(page) { switch (page) { @@ -317,13 +340,18 @@ export default { .catch(FileBrowser.openFolderError); }, "prev-file"() { - const len = editorManager.files.length; - let fileIndex = editorManager.files.indexOf(editorManager.activeFile); + const files = + editorManager.getPaneFiles?.(editorManager.activeFile) || + editorManager.files; + const len = files.length; + let fileIndex = files.indexOf(editorManager.activeFile); + + if (!len || fileIndex === -1) return; if (fileIndex === 0) fileIndex = len - 1; else --fileIndex; - editorManager.files[fileIndex].makeActive(); + files[fileIndex].makeActive(); }, "read-only"() { const file = editorManager.activeFile; diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 2fe93edc1..8877413ed 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -269,6 +269,9 @@ function maybeRecommendLanguageModeExtension(file, modeInfo) { * @property {number} [savedMtime] file mtime last saved or loaded from disk * @property {number} [diskMtime] latest known file mtime on disk * @property {boolean} [hasDiskConflict] whether editor and disk both changed + * @property {string} [paneId] target editor pane id + * @property {object} [pane] target editor pane + * @property {boolean} [isPanePlaceholder] temporary empty tab for an empty pane */ export default class EditorFile { @@ -437,6 +440,7 @@ export default class EditorFile { savedMtime = null; diskMtime = null; hasDiskConflict = false; + isPanePlaceholder = false; /** * @@ -448,6 +452,8 @@ export default class EditorFile { let doesExists = null; this.hideQuickTools = options?.hideQuickTools || false; + this.paneId = options?.paneId || options?.pane?.id || null; + this.isPanePlaceholder = !!options?.isPanePlaceholder; // if options are passed if (options) { @@ -960,6 +966,7 @@ export default class EditorFile { markEdited() { if (this.type !== "editor") return; + this.isPanePlaceholder = false; if (this.id === config.DEFAULT_FILE_SESSION) { this.id = helpers.uuid(); } @@ -1198,7 +1205,11 @@ export default class EditorFile { * @param {boolean} force if true, will prompt to save the file */ async remove(force = false, options = {}) { - const { ignorePinned = false, silentPinned = false } = options || {}; + const { + ignorePinned = false, + silentPinned = false, + suppressPanePlaceholder = false, + } = options || {}; if (this.id === config.DEFAULT_FILE_SESSION && !editorManager.files.length) return false; @@ -1221,9 +1232,12 @@ export default class EditorFile { this.#destroy(); - editorManager.files = editorManager.files.filter( - (file) => file.id !== this.id, - ); + const removal = editorManager.removeFileFromPane?.(this); + if (!removal) { + editorManager.files = editorManager.files.filter( + (file) => file.id !== this.id, + ); + } const { files, activeFile } = editorManager; const wasActive = activeFile?.id === this.id; if (wasActive) { @@ -1233,8 +1247,24 @@ export default class EditorFile { Sidebar.hide(); editorManager.activeFile = null; new EditorFile(); + } else if (removal?.wasPaneActive && removal.nextFile) { + removal.nextFile.makeActive(); + } else if ( + removal?.wasPaneActive && + removal.pane && + !removal.nextFile && + !suppressPanePlaceholder + ) { + new EditorFile(config.DEFAULT_FILE_NAME, { + paneId: removal.pane.id, + text: "", + isUnsaved: false, + isPanePlaceholder: true, + }); } else if (wasActive) { - files[files.length - 1].makeActive(); + ( + editorManager.activePane?.activeFile || files[files.length - 1] + ).makeActive(); } editorManager.onupdate("remove-file"); editorManager.emit("remove-file", this); @@ -1326,20 +1356,24 @@ export default class EditorFile { * Makes this file active */ makeActive() { - const { activeFile, editor, switchFile } = editorManager; - - if (activeFile) { - if (activeFile.id === this.id) return; + const pane = editorManager.getFilePane?.(this) || editorManager.activePane; + const wasActivePane = editorManager.activePane?.id === pane?.id; + const { activeFile, switchFile } = editorManager; + const paneActiveFile = pane?.activeFile; + + if (paneActiveFile && paneActiveFile.id !== this.id) { + paneActiveFile.focusedBefore = paneActiveFile.focused; + paneActiveFile.removeActive(); + } else if (activeFile && (activeFile.id !== this.id || !wasActivePane)) { activeFile.focusedBefore = activeFile.focused; activeFile.removeActive(); - - // Hide previous content if it exists - if (activeFile.type !== "editor" && activeFile.content) { - activeFile.content.style.display = "none"; - } } - switchFile(this.id); + if (activeFile?.id === this.id && wasActivePane) return; + + switchFile(this.id, pane); + + const { editor } = editorManager; // Show/hide appropriate content if (this.type === "editor") { @@ -1359,7 +1393,9 @@ export default class EditorFile { editorManager.container.style.display = "none"; if (this.content) { this.content.style.display = "block"; - if (!this.content.parentElement) { + if ( + this.content.parentElement !== editorManager.container.parentElement + ) { editorManager.container.parentElement.appendChild(this.content); } } @@ -1440,11 +1476,27 @@ export default class EditorFile { this.makeActive(); if (this.id !== config.DEFAULT_FILE_SESSION) { + const pane = editorManager.getFilePane?.(this); const defaultFile = editorManager.getFile( config.DEFAULT_FILE_SESSION, "id", ); - defaultFile?.remove(); + if (defaultFile && editorManager.getFilePane?.(defaultFile) === pane) { + defaultFile.remove(); + } + + editorManager + .getPaneFiles?.(this) + ?.filter( + (file) => + file !== this && + file.isPanePlaceholder && + !file.isUnsaved && + editorManager.getFilePane?.(file) === pane, + ) + .forEach((file) => { + file.remove(true, { ignorePinned: true }); + }); } // Show/hide editor based on content type @@ -1455,7 +1507,11 @@ export default class EditorFile { editorManager.container.style.display = "none"; if (this.#content) { this.#content.style.display = "block"; - editorManager.container.parentElement.appendChild(this.#content); + if ( + this.#content.parentElement !== editorManager.container.parentElement + ) { + editorManager.container.parentElement.appendChild(this.#content); + } } } } diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 3f96314b0..27c653e89 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -76,6 +76,7 @@ import quickTools from "components/quickTools"; import ScrollBar from "components/scrollbar"; import SideButton, { sideButtonContainer } from "components/sideButton"; import keyboardHandler, { keydownState } from "handlers/keyboard"; +import config from "./config"; import EditorFile from "./editorFile"; import openFile from "./openFile"; import { addedFolder } from "./openFolder"; @@ -112,6 +113,8 @@ async function EditorManager($header, $body) { let scrollRestoreFrame = 0; let scrollRestoreNestedFrame = 0; let scrollRestoreTimeout = 0; + const MIN_PANE_WIDTH = 360; + const MIN_RESIZED_PANE_WIDTH = 280; // Debounce timers for CodeMirror change handling let checkTimeout = null; @@ -176,12 +179,14 @@ async function EditorManager($header, $body) { events[event].forEach((fn) => fn(...args)); }, }; - const $container =
; - // Ensure the container participates well in flex layouts and can constrain the editor - $container.style.flex = "1 1 auto"; - $container.style.minHeight = "0"; // allow child scroller to size correctly - $container.style.height = "100%"; - $container.style.width = "100%"; + let manager; + let paneIdCounter = 0; + let activePane = null; + let editor = null; + const panes = []; + const $paneRoot =
; + let $container = createEditorContainer(); + const primaryPane = createPaneShell($container); const problemButton = SideButton({ text: strings.problems, icon: "warningreport_problem", @@ -192,6 +197,151 @@ async function EditorManager($header, $body) { }, }); + function createEditorContainer() { + const $el =
; + // Ensure the container participates well in flex layouts and can constrain the editor. + $el.style.flex = "1 1 auto"; + $el.style.minHeight = "0"; + $el.style.height = "100%"; + $el.style.width = "100%"; + return $el; + } + + function createPaneShell(editorContainer = createEditorContainer()) { + const pane = { + id: `pane-${++paneIdCounter}`, + files: [], + activeFile: null, + editor: null, + editorContainer, + touchSelectionController: null, + element:
, + resizeHandle:
, + tabList: , + content:
, + }; + + pane.element.dataset.paneId = pane.id; + pane.tabList.dataset.paneId = pane.id; + pane.element.__editorPane = pane; + pane.tabList.__editorPane = pane; + pane.content.__editorPane = pane; + pane.resizeHandle.__editorPane = pane; + pane.editorContainer.__editorPane = pane; + pane.content.append(pane.editorContainer); + pane.element.append(pane.resizeHandle, pane.tabList, pane.content); + pane.resizeHandle.addEventListener("pointerdown", (event) => { + startPaneResize(event, pane); + }); + pane.element.addEventListener( + "pointerdown", + () => { + setActivePane(pane); + }, + true, + ); + panes.push(pane); + $paneRoot.append(pane.element); + updatePaneResizeHandles(); + return pane; + } + + function updatePaneResizeHandles() { + panes.forEach((pane, index) => { + pane.resizeHandle.hidden = index === 0 || panes.length <= 1; + }); + } + + function startPaneResize(event, pane) { + const paneIndex = panes.indexOf(pane); + const previousPane = panes[paneIndex - 1]; + if (!previousPane) return; + + event.preventDefault(); + event.stopPropagation(); + + const startX = event.clientX; + const previousRect = previousPane.element.getBoundingClientRect(); + const paneRect = pane.element.getBoundingClientRect(); + const totalWidth = previousRect.width + paneRect.width; + const minWidth = Math.min(MIN_RESIZED_PANE_WIDTH, totalWidth / 2); + + document.body.classList.add("resizing-editor-pane"); + pane.resizeHandle.setPointerCapture?.(event.pointerId); + + const resize = (moveEvent) => { + const delta = moveEvent.clientX - startX; + const previousWidth = Math.max( + minWidth, + Math.min(totalWidth - minWidth, previousRect.width + delta), + ); + const nextWidth = totalWidth - previousWidth; + previousPane.element.style.flex = `0 1 ${previousWidth}px`; + pane.element.style.flex = `0 1 ${nextWidth}px`; + }; + + const stop = () => { + document.removeEventListener("pointermove", resize); + document.removeEventListener("pointerup", stop); + document.removeEventListener("pointercancel", stop); + document.body.classList.remove("resizing-editor-pane"); + }; + + document.addEventListener("pointermove", resize); + document.addEventListener("pointerup", stop); + document.addEventListener("pointercancel", stop); + } + + function getActivePane() { + return activePane || panes[0] || null; + } + + function setActivePane(pane, options = {}) { + if (!pane || activePane === pane) return pane; + + activePane?.element.classList.remove("active"); + activePane = pane; + editor = pane.editor || editor; + $container = pane.editorContainer || $container; + touchSelectionController = pane.touchSelectionController || null; + pane.element.classList.add("active"); + + if (manager) { + manager.activeFile = pane.activeFile || null; + updateHeaderForFile(manager.activeFile); + updateActivePaneScrollbars(); + toggleProblemButton(); + if (options.emitSwitch !== false && manager.activeFile) { + manager.onupdate("switch-file"); + events.emit("switch-file", manager.activeFile); + } + if (options.configureLsp !== false) { + if (manager.activeFile?.type === "editor") { + void configureLspForFile(manager.activeFile); + } else { + detachActiveLsp(); + } + } + } + + return pane; + } + + function updateHeaderForFile(file) { + if (!$header) return; + $header.text = file?.filename || ""; + $header.subText = file?.headerSubtitle || ""; + } + + function updateActivePaneScrollbars() { + $hScrollbar?.hideImmediately?.(); + $vScrollbar?.hideImmediately?.(); + setVScrollValue(); + if (!appSettings.value.textWrap) { + setHScrollValue(); + } + } + const pointerCursorVisibilityExtension = EditorView.updateListener.of( (update) => { if (!update.transactions.length) return; @@ -686,26 +836,40 @@ async function EditorManager($header, $body) { ]; } - function applyOptions(keys) { + function applyOptions(keys, targetEditor = null) { const filter = keys ? new Set(keys) : null; - for (const spec of cmOptionSpecs) { - if (filter && !spec.keys.some((k) => filter.has(k))) continue; - const built = spec.build(); - const effects = []; - if (spec.compartments.length === 1) { - effects.push(spec.compartments[0].reconfigure(built)); - } else { - const arr = Array.isArray(built) ? built : [built]; - for (let i = 0; i < spec.compartments.length; i++) { - const comp = spec.compartments[i]; - const ext = arr[i] ?? []; - effects.push(comp.reconfigure(ext)); + const targetEditors = targetEditor + ? [targetEditor] + : panes.map((pane) => pane.editor).filter(Boolean); + + for (const target of targetEditors) { + for (const spec of cmOptionSpecs) { + if (filter && !spec.keys.some((k) => filter.has(k))) continue; + const built = spec.build(); + const effects = []; + if (spec.compartments.length === 1) { + effects.push(spec.compartments[0].reconfigure(built)); + } else { + const arr = Array.isArray(built) ? built : [built]; + for (let i = 0; i < spec.compartments.length; i++) { + const comp = spec.compartments[i]; + const ext = arr[i] ?? []; + effects.push(comp.reconfigure(ext)); + } } + target.dispatch({ effects }); } - editor.dispatch({ effects }); } } + function setThemeForEditors(themeId) { + panes.forEach((pane) => { + if (pane.editor) { + applyThemeToEditor(pane.editor, themeId); + } + }); + } + function buildLspMetadata(file) { if (!file || file.type !== "editor") return null; const uri = getFileLspUri(file); @@ -937,32 +1101,41 @@ async function EditorManager($header, $body) { } // Create minimal CodeMirror editor - - const editorState = EditorState.create({ - doc: "", - extensions: createMainEditorExtensions({ - // Emmet needs highest precedence so place before default keymaps - emmetExtensions: createEmmetExtensionSet({ - syntax: EmmetKnownSyntax.html, + function createEmptyEditorState() { + return EditorState.create({ + doc: "", + extensions: createMainEditorExtensions({ + // Emmet needs highest precedence so place before default keymaps + emmetExtensions: createEmmetExtensionSet({ + syntax: EmmetKnownSyntax.html, + }), + baseExtensions: createConfiguredBaseExtensions(), + commandKeymapExtension: getCommandKeymapExtension(), + themeExtension: themeCompartment.of(getConfiguredThemeExtension()), + pointerCursorVisibilityExtension, + shiftClickSelectionExtension, + touchSelectionUpdateExtension, + searchExtension: search(), + // Ensure read-only can be toggled later via compartment + readOnlyExtension: readOnlyCompartment.of( + EditorState.readOnly.of(false), + ), + // Editor options driven by settings via compartments + optionExtensions: getBaseExtensionsFromOptions(), }), - baseExtensions: createConfiguredBaseExtensions(), - commandKeymapExtension: getCommandKeymapExtension(), - themeExtension: themeCompartment.of(getConfiguredThemeExtension()), - pointerCursorVisibilityExtension, - shiftClickSelectionExtension, - touchSelectionUpdateExtension, - searchExtension: search(), - // Ensure read-only can be toggled later via compartment - readOnlyExtension: readOnlyCompartment.of(EditorState.readOnly.of(false)), - // Editor options driven by settings via compartments - optionExtensions: getBaseExtensionsFromOptions(), - }), - }); + }); + } - const editor = new EditorView({ + const editorState = createEmptyEditorState(); + + editor = new EditorView({ state: editorState, parent: $container, }); + editor.__editorPane = primaryPane; + primaryPane.editor = editor; + activePane = primaryPane; + primaryPane.element.classList.add("active"); await applyKeyBindings(editor); @@ -1007,6 +1180,7 @@ async function EditorManager($header, $body) { getActiveFile: () => manager?.activeFile || null, isShiftSelectionActive, }); + primaryPane.touchSelectionController = touchSelectionController; // Provide minimal Ace-like API compatibility used by plugins /** @@ -1033,15 +1207,19 @@ async function EditorManager($header, $body) { }; // Set CodeMirror theme by id registered in our registry - editor.setTheme = function (themeId) { + function applyThemeToEditor(targetEditor, themeId) { try { const id = String(themeId || ""); const ext = getThemeExtensions(id, [oneDark]); - editor.dispatch({ effects: themeCompartment.reconfigure(ext) }); + targetEditor.dispatch({ effects: themeCompartment.reconfigure(ext) }); return true; } catch (_) { return false; } + } + + editor.setTheme = function (themeId) { + return applyThemeToEditor(editor, themeId); }; /** @@ -1301,6 +1479,172 @@ async function EditorManager($header, $body) { touchSelectionController?.setMenu(!!value); }; + const editorCompatibilityKeys = [ + "execCommand", + "commands", + "session", + "insert", + "setTheme", + "gotoLine", + "getCursorPosition", + "getSelectionRange", + "scrollToRow", + "moveCursorToPosition", + "getValue", + "selection", + "getCopyText", + "setSelection", + "setMenu", + ]; + const editorCompatibilityDescriptors = Object.fromEntries( + editorCompatibilityKeys + .map((key) => [key, Object.getOwnPropertyDescriptor(editor, key)]) + .filter(([, descriptor]) => descriptor), + ); + + function applyEditorCompatibility(targetEditor) { + Object.defineProperties(targetEditor, editorCompatibilityDescriptors); + } + + function canCreatePane() { + const width = + $paneRoot.getBoundingClientRect?.().width || $body.clientWidth || 0; + if (!width) return true; + return width / (panes.length + 1) >= MIN_PANE_WIDTH; + } + + function createUntitledPaneFile(pane) { + return new EditorFile(config.DEFAULT_FILE_NAME, { + paneId: pane.id, + text: "", + isUnsaved: false, + isPanePlaceholder: true, + }); + } + + async function createPane(options = {}) { + if (!canCreatePane()) { + window.toast?.( + strings["not enough space"] || + "Not enough space to create another editor pane.", + ); + return null; + } + + const pane = createPaneShell(); + const paneEditor = new EditorView({ + state: createEmptyEditorState(), + parent: pane.editorContainer, + }); + pane.editor = paneEditor; + paneEditor.__editorPane = pane; + applyEditorCompatibility(paneEditor); + await applyKeyBindings(paneEditor); + pane.touchSelectionController = createTouchSelectionMenu(paneEditor, { + container: pane.editorContainer, + getActiveFile: () => pane.activeFile || null, + isShiftSelectionActive, + }); + try { + paneEditor.dispatch({ + effects: StateEffect.appendConfig.of(getDocSyncListener()), + }); + } catch (error) { + warnRecoverable( + "Failed to attach document sync listener to split editor.", + error, + `doc-sync-listener-${pane.id}`, + ); + } + await setupEditor(pane); + $paneRoot.classList.toggle("multi-pane", panes.length > 1); + updatePaneResizeHandles(); + syncOpenFileList(); + + if (options.moveFile) { + moveFileToPane(options.moveFile, pane, { activate: true }); + } else if (options.createUntitled !== false) { + createUntitledPaneFile(pane); + } else if (options.activate !== false) { + setActivePane(pane); + pane.editor?.focus(); + } + + return pane; + } + + function splitPane() { + return createPane(); + } + + async function moveActiveFileToNewPane() { + const file = manager.activeFile; + if (!file) return null; + return createPane({ moveFile: file }); + } + + function closeActivePane() { + const pane = getActivePane(); + if (!pane || panes.length <= 1) return false; + + const paneIndex = panes.indexOf(pane); + const targetPane = + panes[paneIndex - 1] || panes[paneIndex + 1] || panes[0] || null; + if (!targetPane) return false; + + for (const file of [...pane.files]) { + if (file.isPanePlaceholder && !file.isUnsaved) { + file.remove(true, { + ignorePinned: true, + suppressPanePlaceholder: true, + }); + continue; + } + + moveFileToPane(file, targetPane, { + activate: false, + createSourcePlaceholder: false, + }); + } + + pane.editor?.destroy?.(); + pane.element.remove(); + panes.splice(paneIndex, 1); + $paneRoot.classList.toggle("multi-pane", panes.length > 1); + updatePaneResizeHandles(); + rebuildFileListFromPanes(); + const fileToActivate = targetPane.activeFile; + targetPane.activeFile = null; + setActivePane(targetPane, { emitSwitch: false }); + fileToActivate?.makeActive(); + syncOpenFileList(); + return true; + } + + function focusPaneByOffset(offset) { + if (panes.length <= 1) return false; + const index = Math.max(0, panes.indexOf(getActivePane())); + const nextPane = panes[(index + offset + panes.length) % panes.length]; + if (!nextPane) return false; + const fileToActivate = nextPane.activeFile; + nextPane.activeFile = null; + setActivePane(nextPane, { emitSwitch: false }); + if (fileToActivate) { + fileToActivate.makeActive(); + } else { + nextPane.editor?.focus(); + } + return true; + } + + function focusNextPane() { + return focusPaneByOffset(1); + } + + function focusPreviousPane() { + return focusPaneByOffset(-1); + } + function getEditorExtensionSignature(file) { return JSON.stringify({ syntax: getEmmetSyntaxForFile(file), @@ -1715,31 +2059,54 @@ async function EditorManager($header, $body) { parent: $body, placement: "bottom", }); - const manager = { + manager = { files: [], onupdate: () => {}, activeFile: null, isCodeMirror: true, addFile, - editor, readOnlyCompartment, getFile, + getFilePane, + getPaneFiles, + setActivePane, reapplyActiveFile, switchFile, + createPane, + splitPane, + closeActivePane, + focusNextPane, + focusPreviousPane, + moveActiveFileToNewPane, + moveFileToPane, + removeFileFromPane, moveFileByPinnedState, normalizePinnedTabOrder, + updatePaneFileOrderFromTabs, syncOpenFileList, hasUnsavedFiles, getEditorHeight, getEditorWidth, header: $header, - container: $container, getLspMetadata: buildLspMetadata, + get editor() { + return getActivePane()?.editor || editor; + }, + get activePane() { + return getActivePane(); + }, + get panes() { + return panes.slice(); + }, + get container() { + return getActivePane()?.editorContainer || $container; + }, get isScrolling() { return isScrolling; }, get openFileList() { if (!$openFileList) initFileTabContainer(); + if (isPaneTabLayout()) return getActivePane()?.tabList || $openFileList; return $openFileList; }, get TIMEOUT_VALUE() { @@ -1884,9 +2251,9 @@ async function EditorManager($header, $body) { }); applyLspSettings(); - $body.append($container); + $body.append($paneRoot); initModes(); // Initialize CodeMirror modes - await setupEditor(); + await setupEditor(primaryPane); // Initialize theme from settings or fallback try { @@ -2047,7 +2414,7 @@ async function EditorManager($header, $body) { appSettings.on("update:editorTheme", function () { const desiredTheme = appSettings?.value?.editorTheme || "one_dark"; - editor.setTheme(desiredTheme); + setThemeForEditors(desiredTheme); applyOptions(["rainbowBrackets"]); }); @@ -2108,7 +2475,8 @@ async function EditorManager($header, $body) { // Keep file.session and cache in sync on every edit function getDocSyncListener() { return EditorView.updateListener.of((update) => { - const file = manager.activeFile; + const pane = update.view.__editorPane || getActivePane(); + const file = pane?.activeFile || manager.activeFile; if (!file || file.type !== "editor") return; if (update.docChanged) { @@ -2221,10 +2589,9 @@ async function EditorManager($header, $body) { */ function addFile(file) { if (manager.files.includes(file)) return; - const insertAt = file.pinned - ? getPinnedInsertIndex() - : manager.files.length; - manager.files.splice(insertAt, 0, file); + const pane = getPaneById(file.paneId) || getActivePane() || primaryPane; + insertFileIntoPane(file, pane); + rebuildFileListFromPanes(); syncOpenFileList(); if (!manager.activeFile) { $header.text = file.name; @@ -2232,14 +2599,47 @@ async function EditorManager($header, $body) { toggleProblemButton(); } - function getPinnedInsertIndex(skipFile = null) { - return manager.files.reduce((count, file) => { + function getPinnedInsertIndex(files, skipFile = null) { + return files.reduce((count, file) => { if (file === skipFile) return count; return count + (file.pinned ? 1 : 0); }, 0); } + function insertFileIntoPane(file, pane, index = null) { + if (!file || !pane) return; + const oldPane = getFilePane(file); + if (oldPane) { + oldPane.files = oldPane.files.filter((paneFile) => paneFile !== file); + } + file.paneId = pane.id; + const insertAt = + Number.isInteger(index) && index >= 0 + ? Math.min(index, pane.files.length) + : file.pinned + ? getPinnedInsertIndex(pane.files) + : pane.files.length; + pane.files.splice(insertAt, 0, file); + } + + function rebuildFileListFromPanes() { + manager.files = panes.flatMap((pane) => pane.files); + return manager.files; + } + function syncOpenFileList() { + if (isPaneTabLayout()) { + $paneRoot.classList.remove("hide-pane-tabs"); + panes.forEach((pane) => { + pane.files.forEach((file) => { + pane.tabList.append(file.tab); + }); + pane.element.classList.toggle("empty", pane.files.length === 0); + }); + return; + } + + $paneRoot.classList.add("hide-pane-tabs"); const $list = manager.openFileList; manager.files.forEach((file) => { $list.append(file.tab); @@ -2247,17 +2647,44 @@ async function EditorManager($header, $body) { } function moveFileByPinnedState(file) { - if (!manager.files.includes(file)) return; + const pane = getFilePane(file); + if (!pane) return; + pane.files = normalizePinnedFiles(pane.files); + rebuildFileListFromPanes(); + syncOpenFileList(); if (manager.activeFile?.id === file.id) { file.tab.scrollIntoView(); } } function normalizePinnedTabOrder(nextFiles = manager.files) { + const pane = + nextFiles.length && + nextFiles.every((file) => getFilePane(file) === getFilePane(nextFiles[0])) + ? getFilePane(nextFiles[0]) + : null; + + if (pane) { + pane.files = normalizePinnedFiles(nextFiles); + rebuildFileListFromPanes(); + syncOpenFileList(); + return pane.files; + } + + panes.forEach((pane) => { + pane.files = normalizePinnedFiles(pane.files); + }); + rebuildFileListFromPanes(); + syncOpenFileList(); + + return manager.files; + } + + function normalizePinnedFiles(files) { const pinnedFiles = []; const regularFiles = []; - nextFiles.forEach((file) => { + files.forEach((file) => { if (file.pinned) { pinnedFiles.push(file); return; @@ -2265,17 +2692,143 @@ async function EditorManager($header, $body) { regularFiles.push(file); }); - manager.files = [...pinnedFiles, ...regularFiles]; + return [...pinnedFiles, ...regularFiles]; + } + + function getPaneById(id) { + if (!id) return null; + return panes.find((pane) => pane.id === id) || null; + } + + function getFilePane(fileOrId) { + const id = typeof fileOrId === "string" ? fileOrId : fileOrId?.id || null; + if (!id) return null; + return ( + panes.find((pane) => pane.files.some((file) => file.id === id)) || null + ); + } + + function getPaneFiles(fileOrPane = getActivePane()) { + const pane = fileOrPane?.files ? fileOrPane : getFilePane(fileOrPane); + return pane?.files || manager.files; + } + + function moveFileToPane(file, targetPane, options = {}) { + if (!file || !targetPane) return false; + const { + activate = true, + index = null, + createSourcePlaceholder = true, + } = options; + const sourcePane = getFilePane(file); + if (sourcePane === targetPane) { + if (activate) file.makeActive(); + return true; + } + + if (sourcePane?.activeFile?.id === file.id && file.type === "editor") { + const sourceEditor = sourcePane.editor; + file.session = getRawEditorState(sourceEditor?.state); + file.lastScrollTop = sourceEditor?.scrollDOM?.scrollTop || 0; + file.lastScrollLeft = sourceEditor?.scrollDOM?.scrollLeft || 0; + } + + insertFileIntoPane(file, targetPane, index); + if (!targetPane.activeFile && !activate) { + targetPane.activeFile = file; + } + rebuildFileListFromPanes(); syncOpenFileList(); - return manager.files; + if (sourcePane?.activeFile?.id === file.id) { + const nextSourceFile = + sourcePane.files[sourcePane.files.length - 1] || null; + sourcePane.activeFile = null; + if (nextSourceFile) { + nextSourceFile.makeActive(); + } else if (createSourcePlaceholder) { + sourcePane.editor?.setState(createEmptyEditorState()); + sourcePane.editorContainer.style.display = "block"; + createUntitledPaneFile(sourcePane); + } + } + + if (activate) { + file.makeActive(); + } + return true; + } + + function removeFileFromPane(file) { + const pane = getFilePane(file); + if (!pane) return null; + const wasPaneActive = pane.activeFile?.id === file.id; + pane.files = pane.files.filter((paneFile) => paneFile !== file); + let nextFile = pane.activeFile; + if (wasPaneActive) { + nextFile = pane.files[pane.files.length - 1] || null; + pane.activeFile = null; + if (!nextFile) { + pane.editor?.setState(createEmptyEditorState()); + pane.editorContainer.style.display = "block"; + } + } + rebuildFileListFromPanes(); + syncOpenFileList(); + return { pane, wasPaneActive, nextFile }; + } + + function updatePaneFileOrderFromTabs($tabList) { + const pane = $tabList?.__editorPane; + if (!pane) return false; + + const nextFiles = [...$tabList.children] + .map(($tab) => pane.files.find((file) => file.tab === $tab)) + .filter(Boolean); + if (!nextFiles.length) return false; + + pane.files = nextFiles; + const pinnedCount = pane.files.filter((file) => file.pinned).length; + const draggedFile = pane.files.find( + (file) => file.tab?.style.opacity === "0.35", + ); + if (draggedFile) { + const draggedIndex = pane.files.indexOf(draggedFile); + let nextPinnedState; + + if (!draggedFile.pinned && draggedIndex < pinnedCount) { + nextPinnedState = true; + } else if (draggedFile.pinned && draggedIndex >= pinnedCount) { + nextPinnedState = false; + } + + if (nextPinnedState !== undefined) { + draggedFile.setPinnedState(nextPinnedState, { reorder: false }); + pane.files = normalizePinnedFiles(pane.files); + } + } + + rebuildFileListFromPanes(); + syncOpenFileList(); + return true; + } + + function isPaneTabLayout() { + const { openFileListPos } = appSettings.value; + return ( + openFileListPos === appSettings.OPEN_FILE_LIST_POS_HEADER || + openFileListPos === appSettings.OPEN_FILE_LIST_POS_BOTTOM + ); } /** * Sets up the editor with various configurations and event listeners. * @returns {Promise} A promise that resolves once the editor is set up. */ - async function setupEditor() { + async function setupEditor(pane = getActivePane()) { + const editor = pane?.editor; + const touchSelectionController = pane?.touchSelectionController; + if (!pane || !editor) return; const settings = appSettings.value; const { leftMargin, textWrap, colorPreview, fontSize, lineHeight } = appSettings.value; @@ -2291,6 +2844,7 @@ async function EditorManager($header, $body) { const scroller = editor.scrollDOM; function syncScrollUi() { + if (pane !== activePane) return; scrollSyncRaf = 0; editor.requestMeasure({ read: () => readScrollMetrics(), @@ -2299,6 +2853,7 @@ async function EditorManager($header, $body) { } function handleEditorScroll() { + if (pane !== activePane) return; if (!scroller) return; if (restoreScrollbarScrollLock()) return; if (!isScrolling) { @@ -2355,8 +2910,9 @@ async function EditorManager($header, $body) { setNativeContextMenuDisabled(isFocused); contentDOM.addEventListener("focus", (_event) => { + setActivePane(pane); setNativeContextMenuDisabled(true); - const { activeFile } = manager; + const activeFile = pane.activeFile; if (activeFile) { activeFile.focused = true; } @@ -2369,7 +2925,7 @@ async function EditorManager($header, $body) { const { hardKeyboardHidden, keyboardHeight } = await getSystemConfiguration(); const blur = () => { - const { activeFile } = manager; + const activeFile = pane.activeFile; if (activeFile) { activeFile.focused = false; activeFile.focusedBefore = false; @@ -2807,8 +3363,9 @@ async function EditorManager($header, $body) { function getDiagnosticStateForFile(file) { if (!file || file.type !== "editor") return null; - if (manager.activeFile?.id === file.id && editor?.state) { - return editor.state; + const pane = getFilePane(file); + if (pane?.activeFile?.id === file.id && pane.editor?.state) { + return pane.editor.state; } return file.session || null; } @@ -2853,26 +3410,28 @@ async function EditorManager($header, $body) { * Switches the active file in the editor. * @param {string} id - The ID of the file to switch to. */ - function switchFile(id) { - const { id: activeFileId } = manager.activeFile || {}; - if (activeFileId === id) return; + function switchFile(id, targetPane = null) { + const pane = targetPane || getFilePane(id) || getActivePane(); + if (!pane) return; + const paneActiveFile = pane.activeFile; + if (paneActiveFile?.id === id && activePane === pane) return; const file = manager.getFile(id); if (!file) return; - manager.activeFile?.tab.classList.remove("active"); + setActivePane(pane, { emitSwitch: false }); // Hide previous content if it was non-editor - if (manager.activeFile?.type !== "editor" && manager.activeFile?.content) { - manager.activeFile.content.style.display = "none"; + if (paneActiveFile?.type !== "editor" && paneActiveFile?.content) { + paneActiveFile.content.style.display = "none"; } // Persist the previous editor's state before switching away - const prev = manager.activeFile; + const prev = paneActiveFile; if (prev?.type === "editor") { - prev.session = getRawEditorState(editor.state); - prev.lastScrollTop = editor.scrollDOM?.scrollTop || 0; - prev.lastScrollLeft = editor.scrollDOM?.scrollLeft || 0; + prev.session = getRawEditorState(pane.editor.state); + prev.lastScrollTop = pane.editor.scrollDOM?.scrollTop || 0; + prev.lastScrollLeft = pane.editor.scrollDOM?.scrollLeft || 0; window.setTimeout(() => { prev.flushCacheWrite?.().catch((error) => { warnRecoverable( @@ -2884,21 +3443,22 @@ async function EditorManager($header, $body) { }, 1000); } + paneActiveFile?.tab.classList.remove("active"); + pane.activeFile = file; manager.activeFile = file; file.tab.classList.add("active"); file.tab.scrollIntoView(); - $header.text = file.filename; - $header.subText = file.headerSubtitle || ""; + updateHeaderForFile(file); if (file.type === "editor") { - touchSelectionController?.setEnabled(true); + pane.touchSelectionController?.setEnabled(true); if (!file.loaded && !file.loading) { showLoadingEditor(file); } else { // Apply active file content and language to CodeMirror applyFileToEditor(file); } - $container.style.display = "block"; + pane.editorContainer.style.display = "block"; $hScrollbar.hideImmediately(); $vScrollbar.hideImmediately(); @@ -2908,12 +3468,12 @@ async function EditorManager($header, $body) { setHScrollValue(); } } else { - touchSelectionController?.setEnabled(false); - $container.style.display = "none"; + pane.touchSelectionController?.setEnabled(false); + pane.editorContainer.style.display = "none"; if (file.content) { file.content.style.display = "block"; - if (!file.content.parentElement) { - $container.parentElement.appendChild(file.content); + if (file.content.parentElement !== pane.content) { + pane.content.appendChild(file.content); } } } @@ -2935,7 +3495,10 @@ async function EditorManager($header, $body) { function initFileTabContainer() { let $list; - if ($openFileList) { + if ( + $openFileList && + !$openFileList.classList.contains("editor-pane-tabs") + ) { if ($openFileList.classList.contains("collapsible")) { $list = Array.from($openFileList.$ul.children); } else { @@ -2946,30 +3509,14 @@ async function EditorManager($header, $body) { // show open file list in header const { openFileListPos } = appSettings.value; - if ( - openFileListPos === appSettings.OPEN_FILE_LIST_POS_HEADER || - openFileListPos === appSettings.OPEN_FILE_LIST_POS_BOTTOM - ) { - if (!$openFileList?.classList.contains("open-file-list")) { - $openFileList = ; - } - if ($list) $openFileList.append(...$list); - - if (openFileListPos === appSettings.OPEN_FILE_LIST_POS_BOTTOM) { - $container.parentElement.insertAdjacentElement( - "afterend", - $openFileList, - ); - } else { - $header.insertAdjacentElement("afterend", $openFileList); - } - - root.classList.add("top-bar"); - - const oldAppend = $openFileList.append; - $openFileList.append = (...args) => { - oldAppend.apply($openFileList, args); - }; + if (isPaneTabLayout()) { + $openFileList = getActivePane()?.tabList || null; + $paneRoot.dataset.tabsPosition = + openFileListPos === appSettings.OPEN_FILE_LIST_POS_BOTTOM + ? "bottom" + : "top"; + root.classList.remove("top-bar"); + syncOpenFileList(); } else { $openFileList = list(strings["active files"]); $openFileList.classList.add("file-list"); @@ -2984,6 +3531,7 @@ async function EditorManager($header, $body) { const files = sidebarApps.get("files"); files.insertBefore($openFileList, files.firstElementChild); root.classList.remove("top-bar"); + syncOpenFileList(); } root.setAttribute("open-file-list-pos", openFileListPos); diff --git a/src/lib/openFile.js b/src/lib/openFile.js index c803a55c2..bbb5e5812 100644 --- a/src/lib/openFile.js +++ b/src/lib/openFile.js @@ -21,6 +21,7 @@ import appSettings from "./settings"; * @property {string} encoding * @property {string} mode * @property {string} uri + * @property {string} paneId */ /** @@ -36,7 +37,7 @@ export default async function openFile(file, options = {}) { /**@type {EditorFile} */ const existingFile = editorManager.getFile(uri, "uri"); - const { cursorPos, render, onsave, text, mode, encoding } = options; + const { cursorPos, render, onsave, text, mode, encoding, paneId } = options; if (existingFile) { // If file is already opened and new text is provided @@ -44,7 +45,16 @@ export default async function openFile(file, options = {}) { text != null ? Text.of(String(text).split("\n")) : null; // If file is already opened - existingFile.makeActive(); + const targetPane = paneId + ? editorManager.panes?.find((pane) => pane.id === paneId) + : null; + if (targetPane) { + editorManager.moveFileToPane?.(existingFile, targetPane, { + activate: true, + }); + } else { + existingFile.makeActive(); + } const { editor } = editorManager; @@ -109,6 +119,7 @@ export default async function openFile(file, options = {}) { SAFMode: mode, savedMtime: helpers.getStatMtime(fileInfo), diskMtime: helpers.getStatMtime(fileInfo), + paneId, }); }; @@ -181,6 +192,7 @@ export default async function openFile(file, options = {}) { content: videoContainer, render: true, hideQuickTools: true, + paneId, }); return; } @@ -349,6 +361,7 @@ export default async function openFile(file, options = {}) { content: imageContainer, render: true, hideQuickTools: true, + paneId, }); return; } @@ -377,6 +390,7 @@ export default async function openFile(file, options = {}) { content: audioPlayer.container, render: true, hideQuickTools: true, + paneId, }); audioTab.onclose = () => { audioPlayer.cleanup(); diff --git a/src/styles/codemirror.scss b/src/styles/codemirror.scss index f0321b160..d3b91e8f3 100644 --- a/src/styles/codemirror.scss +++ b/src/styles/codemirror.scss @@ -2,6 +2,130 @@ position: relative; } +.editor-pane-root { + display: flex; + flex: 1 1 auto; + width: 100%; + height: 100%; + min-height: 0; + min-width: 0; + overflow: hidden; + + &[data-tabs-position="bottom"] { + .editor-pane-tabs { + order: 2; + border-top: 1px solid var(--border-color); + border-bottom: 0; + } + } + + &.hide-pane-tabs { + .editor-pane-tabs { + display: none; + } + } +} + +.editor-pane { + position: relative; + display: flex; + flex: 1 1 0; + flex-direction: column; + min-width: min(360px, 100%); + min-height: 0; + overflow: hidden; + border-left: 1px solid var(--border-color); + background: var(--secondary-color); + + &:first-child { + border-left: 0; + } +} + +.editor-pane-root.multi-pane { + .editor-pane.active { + box-shadow: inset 0 2px 0 var(--active-color); + } + + .editor-pane-resize-handle:not([hidden]) { + display: block; + } +} + +.editor-pane-root:not(.multi-pane) { + .editor-pane-tabs { + border-top: 0; + border-bottom: 0; + } +} + +.editor-pane-resize-handle { + position: absolute; + top: 0; + bottom: 0; + left: -3px; + z-index: 5; + display: none; + width: 6px; + cursor: col-resize; + touch-action: none; + + &::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 2px; + width: 1px; + background: var(--border-color); + } + + &:hover::after, + &:active::after { + background: var(--active-color); + } +} + +body.resizing-editor-pane { + cursor: col-resize; + user-select: none; +} + +.editor-pane-tabs { + flex: 0 0 30px; + border-bottom: 1px solid var(--border-color); + + li.tile { + min-width: min(var(--file-tab-width), 44%); + max-width: var(--file-tab-width); + } +} + +.editor-pane-content { + position: relative; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.editor-pane-content > .editor-container { + height: 100%; +} + +@media (max-width: 720px) { + .editor-pane-root.multi-pane { + .editor-pane { + display: none; + min-width: 100%; + } + + .editor-pane.active { + display: flex; + } + } +} + .editor-container > .cursor-menu { z-index: 600; } From e04709047da6097b57c41c6c566c6ae50ebed8de Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:01:32 +0530 Subject: [PATCH 02/20] add ability to drag one tab from one pane to other --- src/handlers/editorFileTab.js | 172 ++++++++++++++++++++++++++-------- 1 file changed, 131 insertions(+), 41 deletions(-) diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index dae21b84f..c7180d742 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -1,5 +1,6 @@ import config from "lib/config"; import settings from "lib/settings"; +import { animate } from "motion"; const opts = { passive: false }; @@ -18,6 +19,9 @@ let $tab = null; * @type {HTMLDivElement} */ let $parent = null; +let $originParent = null; +let draggedFile = null; +let allowPaneTransfer = true; let MAX_SCROLL = 0; let MIN_SCROLL = 0; @@ -75,11 +79,11 @@ let didReorder = false; const MIN_SCROLL_SPEED = 2; const MAX_SCROLL_SPEED = 14; -const REORDER_DURATION = 280; -const RELEASE_DURATION = 250; -const SPRING_EASING = "cubic-bezier(0.2, 1.2, 0.4, 1)"; +const REORDER_DURATION = 0.28; +const RELEASE_DURATION = 0.25; +const SPRING_EASING = [0.2, 1, 0.4, 1]; -/** @type {WeakMap} */ +/** @type {WeakMap} */ const reorderAnimations = new WeakMap(); /** @@ -98,8 +102,12 @@ export default function startDrag(e) { navigator.vibrate(config.VIBRATION_TIME); } - $tab = e.target; + $tab = e.currentTarget || e.target.closest?.(".tile") || e.target; $parent = $tab.parentElement; + $originParent = $parent; + draggedFile = editorManager.files.find((file) => file.tab === $tab) || null; + allowPaneTransfer = + !!draggedFile && !(draggedFile.isPanePlaceholder && !draggedFile.isUnsaved); $tabClone = $tab.cloneNode(true); initialNextSibling = $tab.nextElementSibling; didReorder = false; @@ -196,11 +204,18 @@ function releaseDrag(e) { /**@type {HTMLDivElement} target tab */ const $target = document.elementFromPoint(clientX, clientY); - const shouldCommitReorder = $parent.contains($target); + const $dropParent = getDropTabList(clientX, clientY); + if ($dropParent && $dropParent !== $parent) { + moveDragToParent($dropParent); + } + const shouldCommitReorder = + $parent.contains($target) || $dropParent === $parent; if (shouldCommitReorder) { updateDragPreview(clientX, clientY); - if (didReorder) { + if ($parent !== $originParent) { + commitPaneTransfer(); + } else if (didReorder) { updateFileList($parent); } } else if ( @@ -245,18 +260,16 @@ function finishDrag(shouldSettleClone) { if (shouldSettleClone) { const rect = $tab.getBoundingClientRect(); - const anim = $tabClone.animate( - [{ transform: `translate3d(${rect.left}px, ${rect.top}px, 0)` }], + animate( + $tabClone, + { transform: `translate3d(${rect.left}px, ${rect.top}px, 0)` }, { duration: document.body.classList.contains("no-animation") ? 0 : RELEASE_DURATION, - easing: SPRING_EASING, - fill: "forwards", + ease: SPRING_EASING, }, - ); - anim.onfinish = cleanupDrag; - anim.oncancel = cleanupDrag; + ).then(cleanupDrag); return; } @@ -267,6 +280,9 @@ function cleanupDrag() { $tab.style.opacity = ""; $tabClone.remove(); $tabClone = null; + $originParent = null; + draggedFile = null; + allowPaneTransfer = true; initialNextSibling = null; didReorder = false; } @@ -277,16 +293,19 @@ function preventDefaultScroll() { function updateDragPreview(clientX, clientY) { const $target = document.elementFromPoint(clientX, clientY); - if ( - !$parent.contains($target) || - $target === $tab || - $tab.contains($target) - ) { + const $dropParent = getDropTabList(clientX, clientY); + if ($dropParent && $dropParent !== $parent) { + moveDragToParent($dropParent); + } + + if (!$target || $target === $tab || $tab.contains($target)) { return; } const $targetTab = $target.closest(".tile"); - if (!$targetTab) return; + if (!$targetTab || !$parent.contains($targetTab)) { + return; + } const rect = $targetTab.getBoundingClientRect(); const midX = rect.left + rect.width / 2; @@ -302,11 +321,49 @@ function updateDragPreview(clientX, clientY) { } function restoreInitialTabPosition() { + if ($parent !== $originParent) { + moveDragToParent($originParent, initialNextSibling); + return; + } + reorderTab( initialNextSibling?.parentElement === $parent ? initialNextSibling : null, ); } +function moveDragToParent($nextParent, $insertBefore = null) { + if (!$nextParent || $nextParent === $parent) return; + + const previousParents = [$parent, $nextParent].filter(Boolean); + const previousRects = captureVisualPositionsForParents(previousParents); + $parent.removeEventListener("scroll", preventDefaultScroll, opts); + + if ($insertBefore?.parentElement === $nextParent) { + $nextParent.insertBefore($tab, $insertBefore); + } else { + $nextParent.appendChild($tab); + } + + previousParents.forEach(($list) => animateTabReorder($list, previousRects)); + $parent = $nextParent; + updateParentMetrics(); + prevScrollLeft = $parent.scrollLeft; + $parent.addEventListener("scroll", preventDefaultScroll, opts); + didReorder = true; +} + +function commitPaneTransfer() { + const targetPane = $parent?.__editorPane; + if (!targetPane || !draggedFile || !allowPaneTransfer) return; + + const index = [...$parent.children].indexOf($tab); + editorManager.moveFileToPane?.(draggedFile, targetPane, { + activate: true, + index, + }); + editorManager.updatePaneFileOrderFromTabs?.($parent); +} + function reorderTab($insertBefore) { const previousRects = captureVisualPositions($parent); @@ -335,10 +392,47 @@ function captureVisualPositions($parent) { ); } +function captureVisualPositionsForParents(parents) { + const rects = new Map(); + parents.forEach(($list) => { + if (!$list) return; + for (const $child of $list.children) { + rects.set($child, $child.getBoundingClientRect()); + } + }); + return rects; +} + +function updateParentMetrics() { + const parentRect = $parent.getBoundingClientRect(); + parentLeft = parentRect.left; + parentRight = parentRect.right; + MAX_SCROLL = $parent.scrollWidth - parentRect.width; + MIN_SCROLL = 0; +} + +function getDropTabList(clientX, clientY) { + const $target = document.elementFromPoint(clientX, clientY); + const $tabList = $target?.closest?.(".editor-pane-tabs"); + if (isValidDropTabList($tabList)) return $tabList; + + const pane = $target?.closest?.(".editor-pane")?.__editorPane; + if (isValidDropTabList(pane?.tabList)) return pane.tabList; + + return $parent; +} + +function isValidDropTabList($tabList) { + if (!$tabList?.__editorPane) return false; + if ($tabList === $originParent) return true; + if (!allowPaneTransfer) return false; + return $tabList.getClientRects().length > 0; +} + /** * Animates the visual change after the DOM order is updated using FLIP. - * Uses WAAPI directly for reliable mid-animation compositing and to - * properly respect the app's no-animation setting. + * Uses Motion for reliable mid-animation compositing and to respect the + * app's no-animation setting. * @param {HTMLElement} $parent * @param {Map} previousRects */ @@ -348,7 +442,7 @@ function animateTabReorder($parent, previousRects) { const oldAnim = reorderAnimations.get($child); if (oldAnim) { - oldAnim.cancel(); + oldAnim.cancel?.(); reorderAnimations.delete($child); } @@ -361,32 +455,28 @@ function animateTabReorder($parent, previousRects) { if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) continue; - const anim = $child.animate( - [ - { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)` }, - { transform: "translate3d(0, 0, 0)" }, - ], + const anim = animate( + $child, + { + transform: [ + `translate3d(${deltaX}px, ${deltaY}px, 0)`, + "translate3d(0, 0, 0)", + ], + }, { duration: document.body.classList.contains("no-animation") ? 0 : REORDER_DURATION, - easing: SPRING_EASING, - fill: "none", - composite: "replace", + ease: SPRING_EASING, }, ); reorderAnimations.set($child, anim); - anim.onfinish = () => { - if (reorderAnimations.get($child) === anim) { - reorderAnimations.delete($child); - } - }; - anim.oncancel = () => { + anim.then(() => { if (reorderAnimations.get($child) === anim) { reorderAnimations.delete($child); } - }; + }); } } @@ -394,13 +484,13 @@ function animateTabReorder($parent, previousRects) { * Scrolls the container using animation frame */ function scrollContainer() { - return animate(); + return step(); - function animate() { + function step() { const scroll = getScroll(); if (!scroll) return; prevScrollLeft = $parent.scrollLeft += scroll; - animationFrame = requestAnimationFrame(animate); + animationFrame = requestAnimationFrame(step); } } From f35b1c2c3a154d052f0a5cb41f7f141bd81182f3 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:02:36 +0530 Subject: [PATCH 03/20] add vertical pane support and keybinds and bug fixes - added vertical pane creation too - vscode style keybinds - resizing of pane - few bug fixes --- src/lib/commands.js | 21 ++ src/lib/editorManager.js | 442 ++++++++++++++++++++++++++++++++----- src/lib/keyBindings.js | 70 ++++++ src/styles/codemirror.scss | 86 ++++++-- 4 files changed, 547 insertions(+), 72 deletions(-) diff --git a/src/lib/commands.js b/src/lib/commands.js index eebb9e4d2..dfdeea43f 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -171,6 +171,12 @@ export default { "split-pane"() { return editorManager.splitPane?.(); }, + "split-pane-right"() { + return editorManager.splitPaneRight?.(); + }, + "split-pane-down"() { + return editorManager.splitPaneDown?.(); + }, "close-pane"() { return editorManager.closeActivePane?.(); }, @@ -180,9 +186,24 @@ export default { "focus-previous-pane"() { return editorManager.focusPreviousPane?.(); }, + "focus-pane-left"() { + return editorManager.focusPaneByDirection?.("left"); + }, + "focus-pane-right"() { + return editorManager.focusPaneByDirection?.("right"); + }, + "focus-pane-up"() { + return editorManager.focusPaneByDirection?.("up"); + }, + "focus-pane-down"() { + return editorManager.focusPaneByDirection?.("down"); + }, "move-tab-to-new-pane"() { return editorManager.moveActiveFileToNewPane?.(); }, + "move-tab-to-new-pane-down"() { + return editorManager.moveActiveFileToNewPane?.("vertical"); + }, "toggle-pin-tab"(referenceFile) { resolveReferenceFile(referenceFile)?.togglePinned?.(); }, diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 27c653e89..8b5d383ec 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -76,6 +76,7 @@ import quickTools from "components/quickTools"; import ScrollBar from "components/scrollbar"; import SideButton, { sideButtonContainer } from "components/sideButton"; import keyboardHandler, { keydownState } from "handlers/keyboard"; +import { animate } from "motion"; import config from "./config"; import EditorFile from "./editorFile"; import openFile from "./openFile"; @@ -114,7 +115,11 @@ async function EditorManager($header, $body) { let scrollRestoreNestedFrame = 0; let scrollRestoreTimeout = 0; const MIN_PANE_WIDTH = 360; + const MIN_PANE_HEIGHT = 220; const MIN_RESIZED_PANE_WIDTH = 280; + const MIN_RESIZED_PANE_HEIGHT = 180; + const PANE_SPLIT_HORIZONTAL = "horizontal"; + const PANE_SPLIT_VERTICAL = "vertical"; // Debounce timers for CodeMirror change handling let checkTimeout = null; @@ -186,7 +191,10 @@ async function EditorManager($header, $body) { const panes = []; const $paneRoot =
; let $container = createEditorContainer(); + let paneLayoutRoot = null; const primaryPane = createPaneShell($container); + paneLayoutRoot = createPaneNode(primaryPane); + $paneRoot.append(paneLayoutRoot.element); const problemButton = SideButton({ text: strings.problems, icon: "warningreport_problem", @@ -216,9 +224,9 @@ async function EditorManager($header, $body) { editorContainer, touchSelectionController: null, element:
, - resizeHandle:
, tabList:
    , content:
    , + layoutNode: null, }; pane.element.dataset.paneId = pane.id; @@ -226,13 +234,9 @@ async function EditorManager($header, $body) { pane.element.__editorPane = pane; pane.tabList.__editorPane = pane; pane.content.__editorPane = pane; - pane.resizeHandle.__editorPane = pane; pane.editorContainer.__editorPane = pane; pane.content.append(pane.editorContainer); - pane.element.append(pane.resizeHandle, pane.tabList, pane.content); - pane.resizeHandle.addEventListener("pointerdown", (event) => { - startPaneResize(event, pane); - }); + pane.element.append(pane.tabList, pane.content); pane.element.addEventListener( "pointerdown", () => { @@ -241,53 +245,261 @@ async function EditorManager($header, $body) { true, ); panes.push(pane); - $paneRoot.append(pane.element); - updatePaneResizeHandles(); return pane; } - function updatePaneResizeHandles() { - panes.forEach((pane, index) => { - pane.resizeHandle.hidden = index === 0 || panes.length <= 1; + function createPaneNode(pane) { + const node = { + type: "pane", + pane, + parent: null, + element: pane.element, + }; + pane.layoutNode = node; + return node; + } + + function createSplitNode(direction) { + const node = { + type: "split", + direction: normalizePaneDirection(direction), + children: [], + parent: null, + element:
    , + }; + node.element.dataset.direction = node.direction; + return node; + } + + function normalizePaneDirection(direction) { + return direction === PANE_SPLIT_VERTICAL || + direction === "down" || + direction === "below" + ? PANE_SPLIT_VERTICAL + : PANE_SPLIT_HORIZONTAL; + } + + function renderPaneLayout(node = paneLayoutRoot) { + if (!node || node.type !== "split") return; + + node.element.dataset.direction = node.direction; + node.element.replaceChildren(); + node.children.forEach((child, index) => { + if (index > 0) { + node.element.append(createPaneSplitHandle(node, index)); + } + node.element.append(child.element); + renderPaneLayout(child); + }); + } + + function createPaneSplitHandle(splitNode, childIndex) { + const $handle =
    ; + $handle.dataset.direction = splitNode.direction; + $handle.addEventListener("pointerdown", (event) => { + startPaneResize(event, splitNode, childIndex, $handle); + }); + return $handle; + } + + function replacePaneLayoutNode(oldNode, nextNode) { + const parent = oldNode?.parent || null; + if (parent) { + const index = parent.children.indexOf(oldNode); + if (index >= 0) { + parent.children[index] = nextNode; + nextNode.parent = parent; + oldNode.parent = null; + renderPaneLayout(parent); + } + return; + } + + paneLayoutRoot = nextNode; + nextNode.parent = null; + $paneRoot.replaceChildren(nextNode.element); + renderPaneLayout(nextNode); + } + + function insertPaneIntoLayout(sourcePane, pane, direction) { + const sourceNode = sourcePane?.layoutNode || paneLayoutRoot; + const paneNode = createPaneNode(pane); + const splitDirection = normalizePaneDirection(direction); + + if (!sourceNode) { + paneLayoutRoot = paneNode; + $paneRoot.replaceChildren(paneNode.element); + return paneNode; + } + + if ( + sourceNode.parent && + sourceNode.parent.type === "split" && + sourceNode.parent.direction === splitDirection + ) { + const parent = sourceNode.parent; + const index = parent.children.indexOf(sourceNode); + parent.children.splice(index + 1, 0, paneNode); + paneNode.parent = parent; + renderPaneLayout(parent); + return paneNode; + } + + const splitNode = createSplitNode(splitDirection); + const oldParent = sourceNode.parent; + const previousFlex = sourceNode.element.style.flex; + splitNode.children = [sourceNode, paneNode]; + splitNode.element.style.flex = previousFlex; + sourceNode.element.style.flex = ""; + paneNode.element.style.flex = ""; + sourceNode.parent = splitNode; + paneNode.parent = splitNode; + + if (oldParent) { + const index = oldParent.children.indexOf(sourceNode); + if (index >= 0) { + oldParent.children[index] = splitNode; + splitNode.parent = oldParent; + renderPaneLayout(oldParent); + } + } else { + paneLayoutRoot = splitNode; + splitNode.parent = null; + $paneRoot.replaceChildren(splitNode.element); + renderPaneLayout(splitNode); + } + return paneNode; + } + + function removePaneFromLayout(pane) { + const node = pane?.layoutNode; + if (!node) { + pane?.element?.remove(); + return; + } + + const parent = node.parent; + if (!parent) { + paneLayoutRoot = null; + node.element.remove(); + pane.layoutNode = null; + return; + } + + const index = parent.children.indexOf(node); + if (index >= 0) { + parent.children.splice(index, 1); + } + node.parent = null; + pane.layoutNode = null; + + if (parent.children.length === 1) { + const onlyChild = parent.children[0]; + onlyChild.element.style.flex = + parent.element.style.flex || onlyChild.element.style.flex; + replacePaneLayoutNode(parent, onlyChild); + parent.children = []; + parent.element.remove(); + return; + } + + renderPaneLayout(parent); + } + + function getOrderedPanes(node = paneLayoutRoot) { + if (!node) return panes.slice(); + if (node.type === "pane") return node.pane ? [node.pane] : []; + return node.children.flatMap((child) => getOrderedPanes(child)); + } + + function updatePaneLayoutState() { + $paneRoot.classList.toggle("multi-pane", panes.length > 1); + renderPaneLayout(); + updateActivePaneLayoutPath(activePane); + } + + function animatePaneEntry(pane) { + if (!pane?.element || document.body.classList.contains("no-animation")) { + return; + } + + pane.element.style.opacity = "0"; + pane.element.style.transform = "scale(0.985)"; + animate( + pane.element, + { opacity: 1, transform: "scale(1)" }, + { type: "spring", stiffness: 360, damping: 32 }, + ).then(() => { + pane.element.style.opacity = ""; + pane.element.style.transform = ""; }); } - function startPaneResize(event, pane) { - const paneIndex = panes.indexOf(pane); - const previousPane = panes[paneIndex - 1]; - if (!previousPane) return; + function startPaneResize(event, splitNode, childIndex, handle) { + const previousNode = splitNode.children[childIndex - 1]; + const nextNode = splitNode.children[childIndex]; + if (!previousNode || !nextNode) return; event.preventDefault(); event.stopPropagation(); - const startX = event.clientX; - const previousRect = previousPane.element.getBoundingClientRect(); - const paneRect = pane.element.getBoundingClientRect(); - const totalWidth = previousRect.width + paneRect.width; - const minWidth = Math.min(MIN_RESIZED_PANE_WIDTH, totalWidth / 2); + const isVertical = splitNode.direction === PANE_SPLIT_VERTICAL; + const axis = isVertical ? "y" : "x"; + const start = isVertical ? event.clientY : event.clientX; + const previousRect = previousNode.element.getBoundingClientRect(); + const nextRect = nextNode.element.getBoundingClientRect(); + const previousSize = isVertical ? previousRect.height : previousRect.width; + const nextSize = isVertical ? nextRect.height : nextRect.width; + const totalSize = previousSize + nextSize; + const minSize = Math.min( + isVertical ? MIN_RESIZED_PANE_HEIGHT : MIN_RESIZED_PANE_WIDTH, + totalSize / 2, + ); + let pendingDelta = 0; + let resizeFrame = 0; document.body.classList.add("resizing-editor-pane"); - pane.resizeHandle.setPointerCapture?.(event.pointerId); + document.body.dataset.editorPaneResizeAxis = axis; + handle.setPointerCapture?.(event.pointerId); const resize = (moveEvent) => { - const delta = moveEvent.clientX - startX; - const previousWidth = Math.max( - minWidth, - Math.min(totalWidth - minWidth, previousRect.width + delta), - ); - const nextWidth = totalWidth - previousWidth; - previousPane.element.style.flex = `0 1 ${previousWidth}px`; - pane.element.style.flex = `0 1 ${nextWidth}px`; + pendingDelta = + (isVertical ? moveEvent.clientY : moveEvent.clientX) - start; + if (resizeFrame) return; + resizeFrame = requestAnimationFrame(() => { + resizeFrame = 0; + const nextPreviousSize = Math.max( + minSize, + Math.min(totalSize - minSize, previousSize + pendingDelta), + ); + const nextCurrentSize = totalSize - nextPreviousSize; + previousNode.element.style.flex = `0 1 ${nextPreviousSize}px`; + nextNode.element.style.flex = `0 1 ${nextCurrentSize}px`; + }); + }; + + const refreshEditors = () => { + getOrderedPanes().forEach((pane) => { + pane.editor?.requestMeasure?.(); + }); + updateActivePaneScrollbars(); }; const stop = () => { + if (resizeFrame) { + cancelAnimationFrame(resizeFrame); + resizeFrame = 0; + } document.removeEventListener("pointermove", resize); document.removeEventListener("pointerup", stop); document.removeEventListener("pointercancel", stop); document.body.classList.remove("resizing-editor-pane"); + delete document.body.dataset.editorPaneResizeAxis; + handle.releasePointerCapture?.(event.pointerId); + requestAnimationFrame(refreshEditors); }; - document.addEventListener("pointermove", resize); + document.addEventListener("pointermove", resize, { passive: true }); document.addEventListener("pointerup", stop); document.addEventListener("pointercancel", stop); } @@ -305,6 +517,7 @@ async function EditorManager($header, $body) { $container = pane.editorContainer || $container; touchSelectionController = pane.touchSelectionController || null; pane.element.classList.add("active"); + updateActivePaneLayoutPath(pane); if (manager) { manager.activeFile = pane.activeFile || null; @@ -327,6 +540,18 @@ async function EditorManager($header, $body) { return pane; } + function updateActivePaneLayoutPath(pane) { + $paneRoot + .querySelectorAll(".editor-pane-split.active-path") + .forEach(($split) => $split.classList.remove("active-path")); + + let node = pane?.layoutNode?.parent || null; + while (node) { + node.element?.classList.add("active-path"); + node = node.parent; + } + } + function updateHeaderForFile(file) { if (!$header) return; $header.text = file?.filename || ""; @@ -1506,14 +1731,33 @@ async function EditorManager($header, $body) { Object.defineProperties(targetEditor, editorCompatibilityDescriptors); } - function canCreatePane() { - const width = - $paneRoot.getBoundingClientRect?.().width || $body.clientWidth || 0; - if (!width) return true; - return width / (panes.length + 1) >= MIN_PANE_WIDTH; + function canCreatePane( + direction = PANE_SPLIT_HORIZONTAL, + sourcePane = getActivePane(), + ) { + const normalizedDirection = normalizePaneDirection(direction); + const rect = sourcePane?.element?.getBoundingClientRect?.() || + $paneRoot.getBoundingClientRect?.() || { + width: $body.clientWidth || 0, + height: $body.clientHeight || 0, + }; + if (normalizedDirection === PANE_SPLIT_VERTICAL) { + if (!rect.height) return true; + return rect.height / 2 >= MIN_PANE_HEIGHT; + } + if (!rect.width) return true; + return rect.width / 2 >= MIN_PANE_WIDTH; } function createUntitledPaneFile(pane) { + const existingPlaceholder = pane?.files?.find( + (file) => file.isPanePlaceholder && !file.isUnsaved, + ); + if (existingPlaceholder) { + if (!pane.activeFile) existingPlaceholder.makeActive(); + return existingPlaceholder; + } + return new EditorFile(config.DEFAULT_FILE_NAME, { paneId: pane.id, text: "", @@ -1522,8 +1766,23 @@ async function EditorManager($header, $body) { }); } + function removePanePlaceholders(pane, exceptFile = null) { + const placeholders = [...(pane?.files || [])].filter( + (file) => + file !== exceptFile && file.isPanePlaceholder && !file.isUnsaved, + ); + placeholders.forEach((file) => { + file.remove(true, { + ignorePinned: true, + suppressPanePlaceholder: true, + }); + }); + } + async function createPane(options = {}) { - if (!canCreatePane()) { + const direction = normalizePaneDirection(options.direction); + const sourcePane = options.sourcePane || getActivePane() || primaryPane; + if (!canCreatePane(direction, sourcePane)) { window.toast?.( strings["not enough space"] || "Not enough space to create another editor pane.", @@ -1532,6 +1791,9 @@ async function EditorManager($header, $body) { } const pane = createPaneShell(); + insertPaneIntoLayout(sourcePane, pane, direction); + updatePaneLayoutState(); + animatePaneEntry(pane); const paneEditor = new EditorView({ state: createEmptyEditorState(), parent: pane.editorContainer, @@ -1557,8 +1819,7 @@ async function EditorManager($header, $body) { ); } await setupEditor(pane); - $paneRoot.classList.toggle("multi-pane", panes.length > 1); - updatePaneResizeHandles(); + updatePaneLayoutState(); syncOpenFileList(); if (options.moveFile) { @@ -1573,23 +1834,35 @@ async function EditorManager($header, $body) { return pane; } - function splitPane() { - return createPane(); + function splitPane(direction = PANE_SPLIT_HORIZONTAL) { + return createPane({ direction }); + } + + function splitPaneRight() { + return splitPane(PANE_SPLIT_HORIZONTAL); } - async function moveActiveFileToNewPane() { + function splitPaneDown() { + return splitPane(PANE_SPLIT_VERTICAL); + } + + async function moveActiveFileToNewPane(direction = PANE_SPLIT_HORIZONTAL) { const file = manager.activeFile; if (!file) return null; - return createPane({ moveFile: file }); + return createPane({ moveFile: file, direction }); } function closeActivePane() { const pane = getActivePane(); if (!pane || panes.length <= 1) return false; - const paneIndex = panes.indexOf(pane); + const orderedPanes = getOrderedPanes(); + const paneIndex = orderedPanes.indexOf(pane); const targetPane = - panes[paneIndex - 1] || panes[paneIndex + 1] || panes[0] || null; + orderedPanes[paneIndex - 1] || + orderedPanes[paneIndex + 1] || + orderedPanes[0] || + null; if (!targetPane) return false; for (const file of [...pane.files]) { @@ -1608,10 +1881,10 @@ async function EditorManager($header, $body) { } pane.editor?.destroy?.(); - pane.element.remove(); - panes.splice(paneIndex, 1); - $paneRoot.classList.toggle("multi-pane", panes.length > 1); - updatePaneResizeHandles(); + removePaneFromLayout(pane); + const storedPaneIndex = panes.indexOf(pane); + if (storedPaneIndex >= 0) panes.splice(storedPaneIndex, 1); + updatePaneLayoutState(); rebuildFileListFromPanes(); const fileToActivate = targetPane.activeFile; targetPane.activeFile = null; @@ -1623,8 +1896,12 @@ async function EditorManager($header, $body) { function focusPaneByOffset(offset) { if (panes.length <= 1) return false; - const index = Math.max(0, panes.indexOf(getActivePane())); - const nextPane = panes[(index + offset + panes.length) % panes.length]; + const orderedPanes = getOrderedPanes(); + const index = Math.max(0, orderedPanes.indexOf(getActivePane())); + const nextPane = + orderedPanes[ + (index + offset + orderedPanes.length) % orderedPanes.length + ]; if (!nextPane) return false; const fileToActivate = nextPane.activeFile; nextPane.activeFile = null; @@ -1645,6 +1922,63 @@ async function EditorManager($header, $body) { return focusPaneByOffset(-1); } + function focusPaneByDirection(direction) { + const active = getActivePane(); + if (!active || panes.length <= 1) return false; + + const activeRect = active.element.getBoundingClientRect(); + const activeCenterX = activeRect.left + activeRect.width / 2; + const activeCenterY = activeRect.top + activeRect.height / 2; + let bestPane = null; + let bestScore = Number.POSITIVE_INFINITY; + + for (const pane of getOrderedPanes()) { + if (pane === active) continue; + const rect = pane.element.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + let axisDistance = 0; + let crossDistance = 0; + + if (direction === "left") { + if (centerX >= activeCenterX) continue; + axisDistance = activeRect.left - rect.right; + crossDistance = Math.abs(centerY - activeCenterY); + } else if (direction === "right") { + if (centerX <= activeCenterX) continue; + axisDistance = rect.left - activeRect.right; + crossDistance = Math.abs(centerY - activeCenterY); + } else if (direction === "up") { + if (centerY >= activeCenterY) continue; + axisDistance = activeRect.top - rect.bottom; + crossDistance = Math.abs(centerX - activeCenterX); + } else if (direction === "down") { + if (centerY <= activeCenterY) continue; + axisDistance = rect.top - activeRect.bottom; + crossDistance = Math.abs(centerX - activeCenterX); + } else { + return false; + } + + const score = Math.max(0, axisDistance) * 1000 + crossDistance; + if (score < bestScore) { + bestScore = score; + bestPane = pane; + } + } + + if (!bestPane) return false; + const fileToActivate = bestPane.activeFile; + bestPane.activeFile = null; + setActivePane(bestPane, { emitSwitch: false }); + if (fileToActivate) { + fileToActivate.makeActive(); + } else { + bestPane.editor?.focus(); + } + return true; + } + function getEditorExtensionSignature(file) { return JSON.stringify({ syntax: getEmmetSyntaxForFile(file), @@ -2074,9 +2408,12 @@ async function EditorManager($header, $body) { switchFile, createPane, splitPane, + splitPaneRight, + splitPaneDown, closeActivePane, focusNextPane, focusPreviousPane, + focusPaneByDirection, moveActiveFileToNewPane, moveFileToPane, removeFileFromPane, @@ -2623,7 +2960,7 @@ async function EditorManager($header, $body) { } function rebuildFileListFromPanes() { - manager.files = panes.flatMap((pane) => pane.files); + manager.files = getOrderedPanes().flatMap((pane) => pane.files); return manager.files; } @@ -2734,6 +3071,9 @@ async function EditorManager($header, $body) { } insertFileIntoPane(file, targetPane, index); + if (!file.isPanePlaceholder || file.isUnsaved) { + removePanePlaceholders(targetPane, file); + } if (!targetPane.activeFile && !activate) { targetPane.activeFile = file; } diff --git a/src/lib/keyBindings.js b/src/lib/keyBindings.js index c80edd32f..39ff3575e 100644 --- a/src/lib/keyBindings.js +++ b/src/lib/keyBindings.js @@ -122,6 +122,76 @@ const APP_BINDING_CONFIG = [ action: "prev-file", readOnly: true, }, + { + name: "splitPaneRight", + description: "Split editor pane right", + key: "Ctrl-\\", + action: "split-pane-right", + readOnly: true, + }, + { + name: "splitPaneDown", + description: "Split editor pane down", + key: "Ctrl-Shift-\\", + action: "split-pane-down", + readOnly: true, + }, + { + name: "closePane", + description: "Close active editor pane", + key: "Ctrl-Shift-W", + action: "close-pane", + readOnly: true, + }, + { + name: "focusNextPane", + description: "Focus next editor pane", + key: null, + action: "focus-next-pane", + readOnly: true, + }, + { + name: "focusPreviousPane", + description: "Focus previous editor pane", + key: null, + action: "focus-previous-pane", + readOnly: true, + }, + { + name: "focusPaneLeft", + description: "Focus editor pane to the left", + key: "Ctrl-Alt-Left", + action: "focus-pane-left", + readOnly: true, + }, + { + name: "focusPaneRight", + description: "Focus editor pane to the right", + key: "Ctrl-Alt-Right", + action: "focus-pane-right", + readOnly: true, + }, + { + name: "focusPaneUp", + description: "Focus editor pane above", + key: "Ctrl-Alt-Up", + action: "focus-pane-up", + readOnly: true, + }, + { + name: "focusPaneDown", + description: "Focus editor pane below", + key: "Ctrl-Alt-Down", + action: "focus-pane-down", + readOnly: true, + }, + { + name: "moveTabToNewPane", + description: "Move current tab to new pane", + key: "Ctrl-Alt-\\", + action: "move-tab-to-new-pane", + readOnly: true, + }, { name: "showSettingsMenu", description: "Show settings menu", diff --git a/src/styles/codemirror.scss b/src/styles/codemirror.scss index d3b91e8f3..cac97f3a2 100644 --- a/src/styles/codemirror.scss +++ b/src/styles/codemirror.scss @@ -26,20 +26,32 @@ } } +.editor-pane-split { + position: relative; + display: flex; + flex: 1 1 0; + min-width: 0; + min-height: 0; + overflow: hidden; + + &[data-direction="horizontal"] { + flex-direction: row; + } + + &[data-direction="vertical"] { + flex-direction: column; + } +} + .editor-pane { position: relative; display: flex; flex: 1 1 0; flex-direction: column; min-width: min(360px, 100%); - min-height: 0; + min-height: min(220px, 100%); overflow: hidden; - border-left: 1px solid var(--border-color); background: var(--secondary-color); - - &:first-child { - border-left: 0; - } } .editor-pane-root.multi-pane { @@ -47,7 +59,7 @@ box-shadow: inset 0 2px 0 var(--active-color); } - .editor-pane-resize-handle:not([hidden]) { + .editor-pane-split-handle { display: block; } } @@ -59,36 +71,54 @@ } } -.editor-pane-resize-handle { - position: absolute; - top: 0; - bottom: 0; - left: -3px; +.editor-pane-split-handle { + position: relative; z-index: 5; display: none; - width: 6px; - cursor: col-resize; + flex: 0 0 1px; + background: var(--border-color); touch-action: none; &::after { content: ""; position: absolute; - top: 0; - bottom: 0; - left: 2px; - width: 1px; - background: var(--border-color); + inset: 0; + background: transparent; } - &:hover::after, - &:active::after { + &:hover, + &:active { background: var(--active-color); } + + &[data-direction="horizontal"] { + width: 1px; + cursor: col-resize; + + &::after { + left: -5px; + right: -5px; + } + } + + &[data-direction="vertical"] { + height: 1px; + cursor: row-resize; + + &::after { + top: -5px; + bottom: -5px; + } + } } body.resizing-editor-pane { cursor: col-resize; user-select: none; + + &[data-editor-pane-resize-axis="y"] { + cursor: row-resize; + } } .editor-pane-tabs { @@ -115,14 +145,28 @@ body.resizing-editor-pane { @media (max-width: 720px) { .editor-pane-root.multi-pane { + .editor-pane-split { + min-width: 100%; + min-height: 100%; + } + + .editor-pane-split > .editor-pane-split:not(.active-path) { + display: none; + } + .editor-pane { display: none; min-width: 100%; + min-height: 100%; } .editor-pane.active { display: flex; } + + .editor-pane-split-handle { + display: none; + } } } From 97add327ce9053110f317994c25b1633dcad8906 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:22:16 +0530 Subject: [PATCH 04/20] fix resize pane empty space --- src/lib/editorManager.js | 15 ++++++++---- src/styles/codemirror.scss | 47 ++++++++++++++++++++++++-------------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 8b5d383ec..d24d1c52a 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -395,8 +395,9 @@ async function EditorManager($header, $body) { if (parent.children.length === 1) { const onlyChild = parent.children[0]; - onlyChild.element.style.flex = - parent.element.style.flex || onlyChild.element.style.flex; + onlyChild.element.style.flex = parent.parent + ? parent.element.style.flex || "1 1 0" + : "1 1 0"; replacePaneLayoutNode(parent, onlyChild); parent.children = []; parent.element.remove(); @@ -473,8 +474,8 @@ async function EditorManager($header, $body) { Math.min(totalSize - minSize, previousSize + pendingDelta), ); const nextCurrentSize = totalSize - nextPreviousSize; - previousNode.element.style.flex = `0 1 ${nextPreviousSize}px`; - nextNode.element.style.flex = `0 1 ${nextCurrentSize}px`; + previousNode.element.style.flex = `1 1 ${nextPreviousSize}px`; + nextNode.element.style.flex = `1 1 ${nextCurrentSize}px`; }); }; @@ -1877,6 +1878,7 @@ async function EditorManager($header, $body) { moveFileToPane(file, targetPane, { activate: false, createSourcePlaceholder: false, + activateSourceFallback: false, }); } @@ -2969,6 +2971,7 @@ async function EditorManager($header, $body) { $paneRoot.classList.remove("hide-pane-tabs"); panes.forEach((pane) => { pane.files.forEach((file) => { + file.tab.classList.toggle("active", pane.activeFile?.id === file.id); pane.tabList.append(file.tab); }); pane.element.classList.toggle("empty", pane.files.length === 0); @@ -3056,6 +3059,7 @@ async function EditorManager($header, $body) { activate = true, index = null, createSourcePlaceholder = true, + activateSourceFallback = true, } = options; const sourcePane = getFilePane(file); if (sourcePane === targetPane) { @@ -3084,7 +3088,8 @@ async function EditorManager($header, $body) { const nextSourceFile = sourcePane.files[sourcePane.files.length - 1] || null; sourcePane.activeFile = null; - if (nextSourceFile) { + file.tab?.classList.remove("active"); + if (nextSourceFile && activateSourceFallback) { nextSourceFile.makeActive(); } else if (createSourcePlaceholder) { sourcePane.editor?.setState(createEmptyEditorState()); diff --git a/src/styles/codemirror.scss b/src/styles/codemirror.scss index cac97f3a2..2205bfb7a 100644 --- a/src/styles/codemirror.scss +++ b/src/styles/codemirror.scss @@ -75,49 +75,62 @@ position: relative; z-index: 5; display: none; - flex: 0 0 1px; - background: var(--border-color); + flex: 0 0 9px; + background: transparent; touch-action: none; - &::after { + &::before { content: ""; position: absolute; - inset: 0; - background: transparent; + background: var(--border-color); } - &:hover, - &:active { + &:hover::before, + &:active::before { background: var(--active-color); } &[data-direction="horizontal"] { - width: 1px; + margin-right: -4px; + margin-left: -4px; cursor: col-resize; - &::after { - left: -5px; - right: -5px; + &::before { + top: 0; + bottom: 0; + left: 4px; + width: 1px; } } &[data-direction="vertical"] { - height: 1px; + margin-top: -4px; + margin-bottom: -4px; cursor: row-resize; - &::after { - top: -5px; - bottom: -5px; + &::before { + top: 4px; + right: 0; + left: 0; + height: 1px; } } } body.resizing-editor-pane { - cursor: col-resize; + cursor: col-resize !important; user-select: none; + * { + cursor: col-resize !important; + } + &[data-editor-pane-resize-axis="y"] { - cursor: row-resize; + cursor: row-resize !important; + + * { + cursor: row-resize !important; + } } } From 3a545438bf58413b6304109d9bb3b54f9731c01d Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:34:45 +0530 Subject: [PATCH 05/20] fix: issues and bugs --- src/lib/editorFile.js | 15 +- src/lib/editorManager.js | 405 ++++++++++++++++++++++++++++++++++--- src/lib/keyBindings.js | 2 +- src/styles/codemirror.scss | 4 + src/styles/page.scss | 14 +- 5 files changed, 396 insertions(+), 44 deletions(-) diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 8877413ed..849dae1e3 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -1360,13 +1360,14 @@ export default class EditorFile { const wasActivePane = editorManager.activePane?.id === pane?.id; const { activeFile, switchFile } = editorManager; const paneActiveFile = pane?.activeFile; - - if (paneActiveFile && paneActiveFile.id !== this.id) { - paneActiveFile.focusedBefore = paneActiveFile.focused; - paneActiveFile.removeActive(); - } else if (activeFile && (activeFile.id !== this.id || !wasActivePane)) { - activeFile.focusedBefore = activeFile.focused; - activeFile.removeActive(); + const inactiveFiles = [paneActiveFile, !wasActivePane ? activeFile : null]; + const blurredFileIds = new Set(); + + for (const file of inactiveFiles) { + if (!file || file.id === this.id || blurredFileIds.has(file.id)) continue; + file.focusedBefore = file.focused; + file.removeActive(); + blurredFileIds.add(file.id); } if (activeFile?.id === this.id && wasActivePane) return; diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index d24d1c52a..1fa9c7831 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -190,6 +190,9 @@ async function EditorManager($header, $body) { let editor = null; const panes = []; const $paneRoot =
    ; + const $globalOpenFileList = ( +
      + ); let $container = createEditorContainer(); let paneLayoutRoot = null; const primaryPane = createPaneShell($container); @@ -523,6 +526,7 @@ async function EditorManager($header, $body) { if (manager) { manager.activeFile = pane.activeFile || null; updateHeaderForFile(manager.activeFile); + if (isPaneTabLayout()) syncGlobalOpenFileListMirror(); updateActivePaneScrollbars(); toggleProblemButton(); if (options.emitSwitch !== false && manager.activeFile) { @@ -1384,6 +1388,7 @@ async function EditorManager($header, $body) { }; Object.defineProperty(editor.commands, "commands", { + configurable: true, get() { const map = {}; getRegisteredCommands().forEach((cmd) => { @@ -1396,8 +1401,9 @@ async function EditorManager($header, $body) { // Provide editor.session for Ace API compatibility // Returns the active file's session (Proxy with Ace-like methods) Object.defineProperty(editor, "session", { + configurable: true, get() { - return manager.activeFile?.session ?? null; + return editor.__editorPane?.activeFile?.session ?? null; }, }); @@ -1705,33 +1711,336 @@ async function EditorManager($header, $body) { touchSelectionController?.setMenu(!!value); }; - const editorCompatibilityKeys = [ - "execCommand", - "commands", - "session", - "insert", - "setTheme", - "gotoLine", - "getCursorPosition", - "getSelectionRange", - "scrollToRow", - "moveCursorToPosition", - "getValue", - "selection", - "getCopyText", - "setSelection", - "setMenu", - ]; - const editorCompatibilityDescriptors = Object.fromEntries( - editorCompatibilityKeys - .map((key) => [key, Object.getOwnPropertyDescriptor(editor, key)]) - .filter(([, descriptor]) => descriptor), - ); + function getEditorCompatibilityPane(targetEditor) { + return targetEditor?.__editorPane || getActivePane(); + } + + function refreshPaneCommandKeymaps() { + panes.forEach((pane) => { + if (pane.editor) refreshCommandKeymap(pane.editor); + }); + } + + function createEditorCommands() { + const commands = { + addCommand(descriptor) { + const command = registerExternalCommand(descriptor); + refreshPaneCommandKeymaps(); + return command; + }, + removeCommand(name) { + if (!name) return; + removeExternalCommand(name); + refreshPaneCommandKeymaps(); + }, + }; + + Object.defineProperty(commands, "commands", { + configurable: true, + get() { + const map = {}; + getRegisteredCommands().forEach((cmd) => { + map[cmd.name] = cmd; + }); + return map; + }, + }); + + return commands; + } + + function createEditorCompatibilityDescriptors(targetEditor) { + const getState = () => targetEditor.state; + const getDoc = () => getState().doc; + const getSelection = () => getState().selection.main; + const getTouchSelectionController = () => + getEditorCompatibilityPane(targetEditor)?.touchSelectionController || + touchSelectionController; + + return { + execCommand: { + configurable: true, + writable: true, + value(commandName, args) { + if (!commandName) return false; + return executeCommand(String(commandName), targetEditor, args); + }, + }, + commands: { + configurable: true, + writable: true, + value: createEditorCommands(), + }, + session: { + configurable: true, + get() { + return ( + getEditorCompatibilityPane(targetEditor)?.activeFile?.session ?? + null + ); + }, + }, + insert: { + configurable: true, + writable: true, + value(text) { + try { + const { from, to } = getSelection(); + const insertText = String(text ?? ""); + targetEditor.dispatch({ + changes: { from, to, insert: insertText }, + selection: { + anchor: from + insertText.length, + head: from + insertText.length, + }, + }); + return true; + } catch (_) { + return false; + } + }, + }, + setTheme: { + configurable: true, + writable: true, + value(themeId) { + return applyThemeToEditor(targetEditor, themeId); + }, + }, + gotoLine: { + configurable: true, + writable: true, + value(line, column = 0, animate = false) { + try { + const state = getState(); + const { doc } = state; + + let targetLine; + let targetColumn = column; + + if (typeof line === "string") { + const match = /^([+-])?(\d+)?(:\d+)?(%)?$/.exec(line.trim()); + if (!match) { + console.warn("Invalid gotoLine format:", line); + return false; + } + + const currentLine = doc.lineAt(state.selection.main.head); + const [, sign, lineNum, colonColumn, percent] = match; + + if (colonColumn) { + targetColumn = Math.max(0, +colonColumn.slice(1) - 1); + } + + const parsedLine = lineNum ? +lineNum : currentLine.number; + + if (lineNum && percent) { + let percentage = parsedLine / 100; + if (sign) { + percentage = + percentage * (sign === "-" ? -1 : 1) + + currentLine.number / doc.lines; + } + targetLine = Math.round(doc.lines * percentage); + } else if (lineNum && sign) { + targetLine = + parsedLine * (sign === "-" ? -1 : 1) + currentLine.number; + } else if (lineNum) { + targetLine = parsedLine; + } else { + targetLine = currentLine.number; + } + } else { + targetLine = line; + } + + const lineNum = Math.max(1, Math.min(targetLine, doc.lines)); + const docLine = doc.line(lineNum); + const col = Math.max(0, Math.min(targetColumn, docLine.length)); + const pos = docLine.from + col; + + targetEditor.dispatch({ + selection: { anchor: pos, head: pos }, + effects: EditorView.scrollIntoView(pos, { y: "center" }), + }); + targetEditor.focus(); + return true; + } catch (error) { + console.error("Error in gotoLine:", error); + return false; + } + }, + }, + getCursorPosition: { + configurable: true, + writable: true, + value() { + try { + const head = getSelection().head; + const cursor = getDoc().lineAt(head); + return { row: cursor.number, column: head - cursor.from }; + } catch (_) { + return { row: 1, column: 0 }; + } + }, + }, + getSelectionRange: { + configurable: true, + writable: true, + value() { + try { + const { from, to } = getSelection(); + const doc = getDoc(); + const fromLine = doc.lineAt(from); + const toLine = doc.lineAt(to); + return { + start: { + row: Math.max(0, fromLine.number - 1), + column: from - fromLine.from, + }, + end: { + row: Math.max(0, toLine.number - 1), + column: to - toLine.from, + }, + }; + } catch (_) { + return { start: { row: 0, column: 0 }, end: { row: 0, column: 0 } }; + } + }, + }, + scrollToRow: { + configurable: true, + writable: true, + value(row) { + try { + const scroller = targetEditor.scrollDOM; + if (!scroller) return false; + + if (row === Number.POSITIVE_INFINITY) { + clearScrollbarScrollLock(); + scroller.scrollTop = Math.max( + scroller.scrollHeight - scroller.clientHeight, + 0, + ); + return true; + } + + const parsedRow = Number(row); + if (!Number.isFinite(parsedRow)) return false; + const aceRow = Math.max(0, Math.floor(parsedRow)); + const lineNum = Math.min(getDoc().lines, aceRow + 1); + const line = getDoc().line(lineNum); + targetEditor.dispatch({ + effects: EditorView.scrollIntoView(line.from, { y: "start" }), + }); + return true; + } catch (_) { + return false; + } + }, + }, + moveCursorToPosition: { + configurable: true, + writable: true, + value(pos) { + try { + const lineNum = Math.max(1, pos.row || 1); + const col = Math.max(0, pos.column || 0); + targetEditor.gotoLine(lineNum, col); + } catch (_) { + // ignore + } + }, + }, + getValue: { + configurable: true, + writable: true, + value() { + try { + return getDoc().toString(); + } catch (_) { + return ""; + } + }, + }, + selection: { + configurable: true, + writable: true, + value: { + get anchor() { + try { + return getSelection().anchor; + } catch (_) { + return 0; + } + }, + getRange() { + try { + const { from, to } = getSelection(); + const doc = getDoc(); + const fromLine = doc.lineAt(from); + const toLine = doc.lineAt(to); + return { + start: { + row: fromLine.number, + column: from - fromLine.from, + }, + end: { + row: toLine.number, + column: to - toLine.from, + }, + }; + } catch (_) { + return { + start: { row: 1, column: 0 }, + end: { row: 1, column: 0 }, + }; + } + }, + getCursor() { + return targetEditor.getCursorPosition(); + }, + }, + }, + getCopyText: { + configurable: true, + writable: true, + value() { + try { + const { from, to } = getSelection(); + if (from === to) return ""; + return getDoc().sliceString(from, to); + } catch (_) { + return ""; + } + }, + }, + setSelection: { + configurable: true, + writable: true, + value(value) { + getTouchSelectionController()?.setSelection(!!value); + }, + }, + setMenu: { + configurable: true, + writable: true, + value(value) { + getTouchSelectionController()?.setMenu(!!value); + }, + }, + }; + } function applyEditorCompatibility(targetEditor) { - Object.defineProperties(targetEditor, editorCompatibilityDescriptors); + Object.defineProperties( + targetEditor, + createEditorCompatibilityDescriptors(targetEditor), + ); } + applyEditorCompatibility(editor); + function canCreatePane( direction = PANE_SPLIT_HORIZONTAL, sourcePane = getActivePane(), @@ -1856,6 +2165,7 @@ async function EditorManager($header, $body) { function closeActivePane() { const pane = getActivePane(); if (!pane || panes.length <= 1) return false; + const preferredFile = pane.activeFile; const orderedPanes = getOrderedPanes(); const paneIndex = orderedPanes.indexOf(pane); @@ -1888,7 +2198,9 @@ async function EditorManager($header, $body) { if (storedPaneIndex >= 0) panes.splice(storedPaneIndex, 1); updatePaneLayoutState(); rebuildFileListFromPanes(); - const fileToActivate = targetPane.activeFile; + const fileToActivate = targetPane.files.includes(preferredFile) + ? preferredFile + : targetPane.activeFile; targetPane.activeFile = null; setActivePane(targetPane, { emitSwitch: false }); fileToActivate?.makeActive(); @@ -2405,6 +2717,7 @@ async function EditorManager($header, $body) { getFile, getFilePane, getPaneFiles, + getPaneTabList, setActivePane, reapplyActiveFile, switchFile, @@ -2437,6 +2750,9 @@ async function EditorManager($header, $body) { get panes() { return panes.slice(); }, + get activePaneTabList() { + return getPaneTabList(); + }, get container() { return getActivePane()?.editorContainer || $container; }, @@ -2444,8 +2760,10 @@ async function EditorManager($header, $body) { return isScrolling; }, get openFileList() { - if (!$openFileList) initFileTabContainer(); - if (isPaneTabLayout()) return getActivePane()?.tabList || $openFileList; + if (isPaneTabLayout()) return $globalOpenFileList; + if (!$openFileList || $openFileList === $globalOpenFileList) { + initFileTabContainer(); + } return $openFileList; }, get TIMEOUT_VALUE() { @@ -2966,6 +3284,18 @@ async function EditorManager($header, $body) { return manager.files; } + function syncGlobalOpenFileListMirror() { + $globalOpenFileList.replaceChildren( + ...manager.files.map((file) => { + const tab = file.tab.cloneNode(true); + tab.dataset.fileId = file.id; + tab.dataset.paneId = file.paneId || ""; + tab.classList.toggle("active", manager.activeFile?.id === file.id); + return tab; + }), + ); + } + function syncOpenFileList() { if (isPaneTabLayout()) { $paneRoot.classList.remove("hide-pane-tabs"); @@ -2976,11 +3306,15 @@ async function EditorManager($header, $body) { }); pane.element.classList.toggle("empty", pane.files.length === 0); }); + syncGlobalOpenFileListMirror(); return; } $paneRoot.classList.add("hide-pane-tabs"); - const $list = manager.openFileList; + if (!$openFileList || $openFileList === $globalOpenFileList) { + initFileTabContainer(); + } + const $list = $openFileList; manager.files.forEach((file) => { $list.append(file.tab); }); @@ -3053,6 +3387,15 @@ async function EditorManager($header, $body) { return pane?.files || manager.files; } + function getPaneTabList(fileOrPane = getActivePane()) { + const pane = fileOrPane?.tabList + ? fileOrPane + : typeof fileOrPane === "string" + ? getPaneById(fileOrPane) || getFilePane(fileOrPane) + : getFilePane(fileOrPane); + return pane?.tabList || null; + } + function moveFileToPane(file, targetPane, options = {}) { if (!file || !targetPane) return false; const { @@ -3096,6 +3439,7 @@ async function EditorManager($header, $body) { sourcePane.editorContainer.style.display = "block"; createUntitledPaneFile(sourcePane); } + syncOpenFileList(); } if (activate) { @@ -3160,6 +3504,7 @@ async function EditorManager($header, $body) { function isPaneTabLayout() { const { openFileListPos } = appSettings.value; + // Sidebar mode keeps the global sidebar list, so pane-local tab bars stay hidden. return ( openFileListPos === appSettings.OPEN_FILE_LIST_POS_HEADER || openFileListPos === appSettings.OPEN_FILE_LIST_POS_BOTTOM @@ -3794,6 +4139,7 @@ async function EditorManager($header, $body) { file.tab.classList.add("active"); file.tab.scrollIntoView(); updateHeaderForFile(file); + if (isPaneTabLayout()) syncGlobalOpenFileListMirror(); if (file.type === "editor") { pane.touchSelectionController?.setEnabled(true); @@ -3842,6 +4188,7 @@ async function EditorManager($header, $body) { if ( $openFileList && + $openFileList !== $globalOpenFileList && !$openFileList.classList.contains("editor-pane-tabs") ) { if ($openFileList.classList.contains("collapsible")) { @@ -3855,7 +4202,7 @@ async function EditorManager($header, $body) { // show open file list in header const { openFileListPos } = appSettings.value; if (isPaneTabLayout()) { - $openFileList = getActivePane()?.tabList || null; + $openFileList = $globalOpenFileList; $paneRoot.dataset.tabsPosition = openFileListPos === appSettings.OPEN_FILE_LIST_POS_BOTTOM ? "bottom" diff --git a/src/lib/keyBindings.js b/src/lib/keyBindings.js index 39ff3575e..1f9a00feb 100644 --- a/src/lib/keyBindings.js +++ b/src/lib/keyBindings.js @@ -139,7 +139,7 @@ const APP_BINDING_CONFIG = [ { name: "closePane", description: "Close active editor pane", - key: "Ctrl-Shift-W", + key: "Ctrl-Alt-W", action: "close-pane", readOnly: true, }, diff --git a/src/styles/codemirror.scss b/src/styles/codemirror.scss index 2205bfb7a..19fd31694 100644 --- a/src/styles/codemirror.scss +++ b/src/styles/codemirror.scss @@ -144,6 +144,10 @@ body.resizing-editor-pane { } } +.editor-global-open-file-list { + display: none !important; +} + .editor-pane-content { position: relative; flex: 1 1 auto; diff --git a/src/styles/page.scss b/src/styles/page.scss index 9c770f5a6..62b2d5c20 100644 --- a/src/styles/page.scss +++ b/src/styles/page.scss @@ -14,7 +14,7 @@ body { } &.fullscreen-mode { - .open-file-list { + .open-file-list:not(.editor-pane-tabs) { height: 30px; left: 40px; } @@ -101,7 +101,7 @@ body { box-shadow: none !important; } - .open-file-list { + .open-file-list:not(.editor-pane-tabs) { top: 0; width: calc(100% - 140px); } @@ -233,7 +233,7 @@ wc-page { box-shadow: 0 -32px 4px var(--box-shadow-color); } - .open-file-list { + .open-file-list:not(.editor-pane-tabs) { top: auto !important; bottom: 0; z-index: 99; @@ -293,7 +293,7 @@ wc-page { &[footer-height="1"] { &[open-file-list-pos="bottom"] { &#root { - .open-file-list { + .open-file-list:not(.editor-pane-tabs) { bottom: 40px; } } @@ -322,7 +322,7 @@ wc-page { &[footer-height="2"] { &[open-file-list-pos="bottom"] { &#root { - .open-file-list { + .open-file-list:not(.editor-pane-tabs) { bottom: 80px; } } @@ -351,7 +351,7 @@ wc-page { &[footer-height="3"] { &[open-file-list-pos="bottom"] { &#root { - .open-file-list { + .open-file-list:not(.editor-pane-tabs) { bottom: 120px; } } @@ -501,4 +501,4 @@ wc-page { .editor-container { height: 100%; } -} \ No newline at end of file +} From 701d1e687d105e904195cd1c973d9d530d4bf5d1 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:23:48 +0530 Subject: [PATCH 06/20] fixed pane focus lifecycle --- src/lib/editorManager.js | 82 +++++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 1fa9c7831..b17179097 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -243,7 +243,7 @@ async function EditorManager($header, $body) { pane.element.addEventListener( "pointerdown", () => { - setActivePane(pane); + activatePane(pane); }, true, ); @@ -545,6 +545,24 @@ async function EditorManager($header, $body) { return pane; } + function activatePane(pane, options = {}) { + if (!pane) return null; + if (activePane === pane) { + if (options.focusEditor !== false) pane.editor?.focus?.(); + return pane; + } + + const fileToActivate = pane.activeFile || null; + if (fileToActivate) { + fileToActivate.makeActive(); + return pane; + } + + setActivePane(pane, { emitSwitch: false }); + if (options.focusEditor !== false) pane.editor?.focus?.(); + return pane; + } + function updateActivePaneLayoutPath(pane) { $paneRoot .querySelectorAll(".editor-pane-split.active-path") @@ -2089,21 +2107,7 @@ async function EditorManager($header, $body) { }); } - async function createPane(options = {}) { - const direction = normalizePaneDirection(options.direction); - const sourcePane = options.sourcePane || getActivePane() || primaryPane; - if (!canCreatePane(direction, sourcePane)) { - window.toast?.( - strings["not enough space"] || - "Not enough space to create another editor pane.", - ); - return null; - } - - const pane = createPaneShell(); - insertPaneIntoLayout(sourcePane, pane, direction); - updatePaneLayoutState(); - animatePaneEntry(pane); + async function createPaneEditor(pane) { const paneEditor = new EditorView({ state: createEmptyEditorState(), parent: pane.editorContainer, @@ -2129,6 +2133,25 @@ async function EditorManager($header, $body) { ); } await setupEditor(pane); + return paneEditor; + } + + async function createPane(options = {}) { + const direction = normalizePaneDirection(options.direction); + const sourcePane = options.sourcePane || getActivePane() || primaryPane; + if (!canCreatePane(direction, sourcePane)) { + window.toast?.( + strings["not enough space"] || + "Not enough space to create another editor pane.", + ); + return null; + } + + const pane = createPaneShell(); + insertPaneIntoLayout(sourcePane, pane, direction); + updatePaneLayoutState(); + animatePaneEntry(pane); + await createPaneEditor(pane); updatePaneLayoutState(); syncOpenFileList(); @@ -2201,9 +2224,11 @@ async function EditorManager($header, $body) { const fileToActivate = targetPane.files.includes(preferredFile) ? preferredFile : targetPane.activeFile; - targetPane.activeFile = null; - setActivePane(targetPane, { emitSwitch: false }); - fileToActivate?.makeActive(); + if (fileToActivate) { + fileToActivate.makeActive(); + } else { + activatePane(targetPane); + } syncOpenFileList(); return true; } @@ -2217,14 +2242,7 @@ async function EditorManager($header, $body) { (index + offset + orderedPanes.length) % orderedPanes.length ]; if (!nextPane) return false; - const fileToActivate = nextPane.activeFile; - nextPane.activeFile = null; - setActivePane(nextPane, { emitSwitch: false }); - if (fileToActivate) { - fileToActivate.makeActive(); - } else { - nextPane.editor?.focus(); - } + activatePane(nextPane); return true; } @@ -2282,14 +2300,7 @@ async function EditorManager($header, $body) { } if (!bestPane) return false; - const fileToActivate = bestPane.activeFile; - bestPane.activeFile = null; - setActivePane(bestPane, { emitSwitch: false }); - if (fileToActivate) { - fileToActivate.makeActive(); - } else { - bestPane.editor?.focus(); - } + activatePane(bestPane); return true; } @@ -3248,6 +3259,7 @@ async function EditorManager($header, $body) { if (manager.files.includes(file)) return; const pane = getPaneById(file.paneId) || getActivePane() || primaryPane; insertFileIntoPane(file, pane); + if (!pane.activeFile) pane.activeFile = file; rebuildFileListFromPanes(); syncOpenFileList(); if (!manager.activeFile) { From c9b22f449940e10358d5cf96686ca3ec80077617 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:50:49 +0530 Subject: [PATCH 07/20] fix --- src/handlers/editorFileTab.js | 7 +- src/lib/editorFile.js | 14 ++- src/lib/editorManager.js | 156 ++++++++++++++++++++++++++++++++-- 3 files changed, 165 insertions(+), 12 deletions(-) diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index c7180d742..4ee7d5734 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -145,6 +145,7 @@ export default function startDrag(e) { $tabClone.style.width = `${rect.width}px`; $tabClone.style.transform = `translate3d(${rect.x}px, ${rect.y}px, 0)`; $tab.style.opacity = "0.35"; + $tab.dataset.editorTabDragging = "true"; app.append($tabClone); $tab.click(); @@ -278,6 +279,7 @@ function finishDrag(shouldSettleClone) { function cleanupDrag() { $tab.style.opacity = ""; + delete $tab.dataset.editorTabDragging; $tabClone.remove(); $tabClone = null; $originParent = null; @@ -361,7 +363,7 @@ function commitPaneTransfer() { activate: true, index, }); - editorManager.updatePaneFileOrderFromTabs?.($parent); + editorManager.updatePaneFileOrderFromTabs?.($parent, { draggedFile }); } function reorderTab($insertBefore) { @@ -523,7 +525,8 @@ function getClientPos(e) { */ function updateFileList($parent) { if (typeof editorManager.updatePaneFileOrderFromTabs === "function") { - if (editorManager.updatePaneFileOrderFromTabs($parent)) return; + if (editorManager.updatePaneFileOrderFromTabs($parent, { draggedFile })) + return; } const pinnedCount = editorManager.files.filter((file) => file.pinned).length; diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 849dae1e3..afc62d13a 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -1210,6 +1210,8 @@ export default class EditorFile { silentPinned = false, suppressPanePlaceholder = false, } = options || {}; + const suppressFallback = + suppressPanePlaceholder && this.isPanePlaceholder && !this.isUnsaved; if (this.id === config.DEFAULT_FILE_SESSION && !editorManager.files.length) return false; @@ -1246,14 +1248,18 @@ export default class EditorFile { if (!files.length) { Sidebar.hide(); editorManager.activeFile = null; - new EditorFile(); - } else if (removal?.wasPaneActive && removal.nextFile) { + if (!suppressFallback) new EditorFile(); + } else if ( + removal?.wasPaneActive && + removal.nextFile && + !suppressFallback + ) { removal.nextFile.makeActive(); } else if ( removal?.wasPaneActive && removal.pane && !removal.nextFile && - !suppressPanePlaceholder + !suppressFallback ) { new EditorFile(config.DEFAULT_FILE_NAME, { paneId: removal.pane.id, @@ -1261,7 +1267,7 @@ export default class EditorFile { isUnsaved: false, isPanePlaceholder: true, }); - } else if (wasActive) { + } else if (wasActive && !suppressFallback) { ( editorManager.activePane?.activeFile || files[files.length - 1] ).makeActive(); diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index b17179097..216b341ef 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -193,6 +193,16 @@ async function EditorManager($header, $body) { const $globalOpenFileList = (
        ); + const globalOpenFileListNative = { + append: $globalOpenFileList.append.bind($globalOpenFileList), + appendChild: $globalOpenFileList.appendChild.bind($globalOpenFileList), + insertBefore: $globalOpenFileList.insertBefore.bind($globalOpenFileList), + prepend: $globalOpenFileList.prepend.bind($globalOpenFileList), + replaceChildren: + $globalOpenFileList.replaceChildren.bind($globalOpenFileList), + }; + const $paneAwareOpenFileList = + createPaneAwareOpenFileListProxy($globalOpenFileList); let $container = createEditorContainer(); let paneLayoutRoot = null; const primaryPane = createPaneShell($container); @@ -218,6 +228,71 @@ async function EditorManager($header, $body) { return $el; } + function createPaneAwareOpenFileListProxy(target) { + return new Proxy(target, { + get(target, prop) { + if (prop === "children" || prop === "childNodes") { + return toDomCollection(getOpenFileListChildren()); + } + if (prop === "childElementCount") + return getOpenFileListChildren().length; + if (prop === "firstChild" || prop === "firstElementChild") { + return getOpenFileListChildren()[0] || null; + } + if (prop === "lastChild" || prop === "lastElementChild") { + const children = getOpenFileListChildren(); + return children[children.length - 1] || null; + } + if (prop === "append" || prop === "appendChild" || prop === "prepend") { + return (...args) => mutateVisibleOpenFileList(prop, args); + } + if (prop === "insertBefore") { + return (node, child) => + mutateVisibleOpenFileList("insertBefore", [node, child]); + } + if (prop === "contains") { + return (node) => + getOpenFileListChildren().some( + (child) => child === node || child.contains(node), + ) || target.contains(node); + } + if (prop === "querySelector") { + return (selector) => queryOpenFileList(selector)[0] || null; + } + if (prop === "querySelectorAll") { + return (selector) => toDomCollection(queryOpenFileList(selector)); + } + if (prop === "getElementsByClassName") { + return (className) => + toDomCollection( + collectFromOpenFileListChildren((child) => [ + ...(child.classList.contains(className) ? [child] : []), + ...child.getElementsByClassName(className), + ]), + ); + } + if (prop === "getElementsByTagName") { + return (tagName) => { + const selector = tagName === "*" ? "*" : tagName; + return toDomCollection(queryOpenFileList(selector)); + }; + } + if (prop === "getClientRects" || prop === "getBoundingClientRect") { + return (...args) => { + const targetList = getOpenFileListMutationTarget(); + return targetList[prop](...args); + }; + } + if (typeof prop === "string" && /^\d+$/.test(prop)) { + return getOpenFileListChildren()[Number(prop)]; + } + + const value = Reflect.get(target, prop, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + } + function createPaneShell(editorContainer = createEditorContainer()) { const pane = { id: `pane-${++paneIdCounter}`, @@ -2771,7 +2846,10 @@ async function EditorManager($header, $body) { return isScrolling; }, get openFileList() { - if (isPaneTabLayout()) return $globalOpenFileList; + if (isPaneTabLayout()) { + syncGlobalOpenFileListMirror(); + return $paneAwareOpenFileList; + } if (!$openFileList || $openFileList === $globalOpenFileList) { initFileTabContainer(); } @@ -3296,8 +3374,72 @@ async function EditorManager($header, $body) { return manager.files; } + function toDomCollection(nodes) { + const collection = [...nodes].filter(Boolean); + collection.item = (index) => collection[index] || null; + return collection; + } + + function getOpenFileListChildren() { + if (!isPaneTabLayout()) { + const list = $openFileList?.$ul || $openFileList; + return list ? [...list.children] : []; + } + + return getOrderedPanes().flatMap((pane) => [...pane.tabList.children]); + } + + function collectFromOpenFileListChildren(collector) { + const result = []; + getOpenFileListChildren().forEach((child) => { + result.push(...collector(child)); + }); + return result; + } + + function queryOpenFileList(selector) { + return collectFromOpenFileListChildren((child) => [ + ...(child.matches?.(selector) ? [child] : []), + ...child.querySelectorAll(selector), + ]); + } + + function getOpenFileListMutationTarget(referenceNode = null) { + if (!isPaneTabLayout()) { + if (!$openFileList || $openFileList === $globalOpenFileList) { + initFileTabContainer(); + } + return $openFileList?.$ul || $openFileList || $globalOpenFileList; + } + + if (referenceNode?.parentElement?.classList?.contains("editor-pane-tabs")) { + return referenceNode.parentElement; + } + + return getPaneTabList() || getActivePane()?.tabList || $globalOpenFileList; + } + + function mutateVisibleOpenFileList(method, args) { + const target = getOpenFileListMutationTarget( + method === "insertBefore" ? args[1] : null, + ); + if (!target || target === $globalOpenFileList) { + return globalOpenFileListNative[method]?.(...args); + } + + if (method === "insertBefore") { + const [node, referenceNode] = args; + return target.insertBefore( + node, + referenceNode?.parentElement === target ? referenceNode : null, + ); + } + + return target[method](...args); + } + function syncGlobalOpenFileListMirror() { - $globalOpenFileList.replaceChildren( + globalOpenFileListNative.replaceChildren( ...manager.files.map((file) => { const tab = file.tab.cloneNode(true); tab.dataset.fileId = file.id; @@ -3479,7 +3621,7 @@ async function EditorManager($header, $body) { return { pane, wasPaneActive, nextFile }; } - function updatePaneFileOrderFromTabs($tabList) { + function updatePaneFileOrderFromTabs($tabList, options = {}) { const pane = $tabList?.__editorPane; if (!pane) return false; @@ -3490,9 +3632,11 @@ async function EditorManager($header, $body) { pane.files = nextFiles; const pinnedCount = pane.files.filter((file) => file.pinned).length; - const draggedFile = pane.files.find( - (file) => file.tab?.style.opacity === "0.35", - ); + const draggedFile = pane.files.includes(options.draggedFile) + ? options.draggedFile + : pane.files.find( + (file) => file.tab?.dataset.editorTabDragging === "true", + ); if (draggedFile) { const draggedIndex = pane.files.indexOf(draggedFile); let nextPinnedState; From e7f4d09489da7e15528eb0cab837ca62b6d7f523 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:11:57 +0530 Subject: [PATCH 08/20] fix debounce timer and load related issue --- src/lib/editorFile.js | 34 +++++++---- src/lib/editorManager.js | 118 ++++++++++++++++++++++++++++++++------- 2 files changed, 121 insertions(+), 31 deletions(-) diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index afc62d13a..8e6c619ba 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -1296,15 +1296,26 @@ export default class EditorFile { } setReadOnly(value) { + const readOnly = !!value; + this.readOnly = readOnly; + this.#editable = !readOnly; + try { - const { editor, readOnlyCompartment } = editorManager; - if (!editor) return; - if (!readOnlyCompartment) return; - editor.dispatch({ - effects: readOnlyCompartment.reconfigure( - EditorState.readOnly.of(!!value), - ), - }); + const { readOnlyCompartment } = editorManager; + if (readOnlyCompartment) { + const pane = editorManager.getFilePane?.(this); + const targetEditor = + pane?.activeFile?.id === this.id + ? pane.editor + : editorManager.activeFile?.id === this.id + ? editorManager.editor + : null; + targetEditor?.dispatch({ + effects: readOnlyCompartment.reconfigure( + EditorState.readOnly.of(readOnly), + ), + }); + } } catch (error) { console.warn( `Failed to update read-only state for ${this.filename || this.uri}`, @@ -1312,9 +1323,6 @@ export default class EditorFile { ); } - // Sync internal flags and header - this.readOnly = !!value; - this.#editable = !this.readOnly; if (editorManager.activeFile?.id === this.id) { editorManager.header.subText = this.#getTitle(); } @@ -1715,7 +1723,9 @@ export default class EditorFile { this.loading = false; const { activeFile, emit } = editorManager; - if (activeFile?.id === this.id) { + const pane = editorManager.getFilePane?.(this); + const isActiveInPane = pane?.activeFile?.id === this.id; + if (isActiveInPane || activeFile?.id === this.id) { this.setReadOnly(editable === false); emit("file-loaded", this); } else if (editable !== undefined) { diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 216b341ef..c52cee025 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -121,9 +121,7 @@ async function EditorManager($header, $body) { const PANE_SPLIT_HORIZONTAL = "horizontal"; const PANE_SPLIT_VERTICAL = "vertical"; - // Debounce timers for CodeMirror change handling - let checkTimeout = null; - let autosaveTimeout = null; + const docSyncTimers = new WeakMap(); let touchSelectionController = null; let touchSelectionSyncRaf = 0; let nativeContextMenuDisabled = null; @@ -137,6 +135,26 @@ async function EditorManager($header, $body) { console.warn(message, error); } + function getDocSyncTimers(file) { + let timers = docSyncTimers.get(file); + if (!timers) { + timers = { + checkTimeout: null, + autosaveTimeout: null, + }; + docSyncTimers.set(file, timers); + } + return timers; + } + + function clearDocSyncTimers(file) { + const timers = docSyncTimers.get(file); + if (!timers) return; + if (timers.checkTimeout) clearTimeout(timers.checkTimeout); + if (timers.autosaveTimeout) clearTimeout(timers.autosaveTimeout); + docSyncTimers.delete(file); + } + function isCoarsePointerDevice() { if (typeof window !== "undefined") { try { @@ -2479,12 +2497,23 @@ async function EditorManager($header, $body) { markLanguageReady(file, languageSignature, false); result .then((ext) => { + const pane = getFilePane(file); + const isGlobalActive = manager.activeFile?.id === fileId; + const isPaneActive = pane?.activeFile?.id === fileId; if ( - manager.activeFile?.id !== fileId || + (!isGlobalActive && !isPaneActive) || file.__cmLanguageSignature !== languageSignature ) { return; } + + if (isPaneActive && pane !== getActivePane()) { + withPaneEditorContext(pane, () => { + dispatchLanguageExtension(file, languageSignature, ext, warnKey); + }); + return; + } + dispatchLanguageExtension(file, languageSignature, ext, warnKey); }) .catch(() => { @@ -2546,10 +2575,51 @@ async function EditorManager($header, $body) { touchSelectionController?.onSessionChanged(); } + function withPaneEditorContext(pane, callback) { + if (!pane?.editor) return callback(); + + const previousEditor = editor; + const previousContainer = $container; + const previousTouchSelectionController = touchSelectionController; + + editor = pane.editor; + $container = pane.editorContainer || $container; + touchSelectionController = pane.touchSelectionController || null; + + try { + return callback(); + } finally { + editor = previousEditor; + $container = previousContainer; + touchSelectionController = previousTouchSelectionController; + } + } + + function applyFileToPaneEditor(file, pane, options = {}) { + if (!file || file.type !== "editor") return false; + if (!pane?.editor || pane.activeFile?.id !== file.id) return false; + const isCurrentPane = pane === getActivePane(); + const paneOptions = { + ...options, + restoreScroll: options.restoreScroll ?? isCurrentPane, + scheduleLsp: options.scheduleLsp ?? isCurrentPane, + }; + if (isCurrentPane) { + applyFileToEditor(file, paneOptions); + } else { + withPaneEditorContext(pane, () => applyFileToEditor(file, paneOptions)); + } + return true; + } + // Helper: apply a file's content and language to the editor view function applyFileToEditor(file, options = {}) { if (!file || file.type !== "editor") return; - const { forceRecreate = false } = options; + const { + forceRecreate = false, + restoreScroll = true, + scheduleLsp = true, + } = options; const extensionSignature = getEditorExtensionSignature(file); const languageSignature = getFileLanguageSignature( file, @@ -2577,8 +2647,8 @@ async function EditorManager($header, $body) { } } - restoreFileScrollPosition(file); - scheduleLspForFile(file); + if (restoreScroll) restoreFileScrollPosition(file); + if (scheduleLsp) scheduleLspForFile(file); return; } @@ -2672,9 +2742,8 @@ async function EditorManager($header, $body) { ); } - restoreFileScrollPosition(file); - - scheduleLspForFile(file); + if (restoreScroll) restoreFileScrollPosition(file); + if (scheduleLsp) scheduleLspForFile(file); } function restoreFileScrollPosition(file) { @@ -3242,10 +3311,12 @@ async function EditorManager($header, $body) { file.markEdited(); // Debounced change handling (unsaved flag, cache, autosave) - if (checkTimeout) clearTimeout(checkTimeout); - if (autosaveTimeout) clearTimeout(autosaveTimeout); + const timers = getDocSyncTimers(file); + if (timers.checkTimeout) clearTimeout(timers.checkTimeout); + if (timers.autosaveTimeout) clearTimeout(timers.autosaveTimeout); - checkTimeout = setTimeout(async () => { + timers.checkTimeout = setTimeout(async () => { + timers.checkTimeout = null; try { file.scheduleCacheWrite(); } catch (error) { @@ -3263,8 +3334,15 @@ async function EditorManager($header, $body) { const { autosave } = appSettings.value; if (file.uri && file.isUnsaved && autosave) { - autosaveTimeout = setTimeout(() => { - acode.exec("save", false); + timers.autosaveTimeout = setTimeout(() => { + timers.autosaveTimeout = null; + file.save()?.catch?.((error) => { + warnRecoverable( + `Failed to autosave ${file.filename || file.uri}`, + error, + `autosave-${file.id}`, + ); + }); }, autosave); } @@ -3275,8 +3353,11 @@ async function EditorManager($header, $body) { // Register critical listeners manager.on(["file-loaded"], (file) => { - if (!file) return; - if (manager.activeFile?.id === file.id && file.type === "editor") { + if (!file || file.type !== "editor") return; + const pane = getFilePane(file); + if (pane?.activeFile?.id === file.id) { + applyFileToPaneEditor(file, pane); + } else if (manager.activeFile?.id === file.id) { applyFileToEditor(file); } }); @@ -3302,6 +3383,7 @@ async function EditorManager($header, $body) { }); manager.on(["remove-file"], (file) => { + clearDocSyncTimers(file); detachLspForFile(file); toggleProblemButton(); }); @@ -3683,8 +3765,6 @@ async function EditorManager($header, $body) { const scrollMarginRight = textWrap ? 0 : leftMargin; const scrollMarginBottom = 0; - let checkTimeout = null; - let autosaveTimeout; let scrollTimeout; let scrollSyncRaf = 0; const scroller = editor.scrollDOM; From 538d96884f98bf5da891a081b1d5733180acd374 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:38:54 +0530 Subject: [PATCH 09/20] fix --- src/lib/editorManager.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index c52cee025..d56b45358 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -2308,6 +2308,8 @@ async function EditorManager($header, $body) { }); } + pane.touchSelectionController?.destroy?.(); + pane.touchSelectionController = null; pane.editor?.destroy?.(); removePaneFromLayout(pane); const storedPaneIndex = panes.indexOf(pane); From 8d9eaeb44189aae349fa0dcd48b316e0520ade60 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:39:02 +0530 Subject: [PATCH 10/20] fix the drag snap-back --- src/handlers/editorFileTab.js | 1 + src/lib/editorManager.js | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index 4ee7d5734..c9795fe44 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -281,6 +281,7 @@ function cleanupDrag() { $tab.style.opacity = ""; delete $tab.dataset.editorTabDragging; $tabClone.remove(); + editorManager.syncOpenFileList?.(); $tabClone = null; $originParent = null; draggedFile = null; diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index d56b45358..d3f0e0503 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -3534,12 +3534,20 @@ async function EditorManager($header, $body) { ); } + function isDraggingFileTab(file) { + return file?.tab?.dataset.editorTabDragging === "true"; + } + function syncOpenFileList() { if (isPaneTabLayout()) { $paneRoot.classList.remove("hide-pane-tabs"); panes.forEach((pane) => { + const preserveCurrentTabOrder = !!pane.tabList.querySelector( + '[data-editor-tab-dragging="true"]', + ); pane.files.forEach((file) => { file.tab.classList.toggle("active", pane.activeFile?.id === file.id); + if (isDraggingFileTab(file) || preserveCurrentTabOrder) return; pane.tabList.append(file.tab); }); pane.element.classList.toggle("empty", pane.files.length === 0); @@ -3634,6 +3642,15 @@ async function EditorManager($header, $body) { return pane?.tabList || null; } + function getPaneFallbackFile(pane) { + if (!pane?.files?.length) return null; + for (let i = pane.files.length - 1; i >= 0; i--) { + const file = pane.files[i]; + if (!isDraggingFileTab(file)) return file; + } + return null; + } + function moveFileToPane(file, targetPane, options = {}) { if (!file || !targetPane) return false; const { @@ -3666,8 +3683,7 @@ async function EditorManager($header, $body) { syncOpenFileList(); if (sourcePane?.activeFile?.id === file.id) { - const nextSourceFile = - sourcePane.files[sourcePane.files.length - 1] || null; + const nextSourceFile = getPaneFallbackFile(sourcePane); sourcePane.activeFile = null; file.tab?.classList.remove("active"); if (nextSourceFile && activateSourceFallback) { From 6278f4dd55f8234fa35014984fe0dd094b326cf9 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:06:43 +0530 Subject: [PATCH 11/20] fix --- src/handlers/editorFileTab.js | 58 ++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index c9795fe44..81b091620 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -205,12 +205,15 @@ function releaseDrag(e) { /**@type {HTMLDivElement} target tab */ const $target = document.elementFromPoint(clientX, clientY); - const $dropParent = getDropTabList(clientX, clientY); + const isPathDropTarget = isFilePathDropTarget($target); + const $dropParent = isPathDropTarget + ? null + : getDropTabList(clientX, clientY); if ($dropParent && $dropParent !== $parent) { moveDragToParent($dropParent); } const shouldCommitReorder = - $parent.contains($target) || $dropParent === $parent; + !!$dropParent && ($parent.contains($target) || $dropParent === $parent); if (shouldCommitReorder) { updateDragPreview(clientX, clientY); @@ -219,23 +222,8 @@ function releaseDrag(e) { } else if (didReorder) { updateFileList($parent); } - } else if ( - $target && - ($target.tagName === "INPUT" || - $target.tagName === "TEXTAREA" || - $target.isContentEditable || - $target.closest(".cm-editor")) - ) { - // If released on an input area or CodeMirror editor - const filePath = editorManager.activeFile.uri; - if (filePath) { - if ($target.closest(".cm-editor")) { - const view = editorManager.editor; - view.dispatch(view.state.replaceSelection(filePath)); - } else { - $target.value += filePath; - } - } + } else if (isPathDropTarget) { + insertDraggedFilePath($target); } const shouldSettleClone = shouldCommitReorder || didReorder; @@ -419,10 +407,38 @@ function getDropTabList(clientX, clientY) { const $tabList = $target?.closest?.(".editor-pane-tabs"); if (isValidDropTabList($tabList)) return $tabList; + if (isFilePathDropTarget($target)) return null; + const pane = $target?.closest?.(".editor-pane")?.__editorPane; - if (isValidDropTabList(pane?.tabList)) return pane.tabList; + if (pane?.tabList !== $parent && isValidDropTabList(pane?.tabList)) { + return pane.tabList; + } + + return null; +} - return $parent; +function isFilePathDropTarget($target) { + return !!( + $target && + ($target.tagName === "INPUT" || + $target.tagName === "TEXTAREA" || + $target.isContentEditable || + $target.closest(".cm-editor")) + ); +} + +function insertDraggedFilePath($target) { + const filePath = draggedFile?.uri || editorManager.activeFile?.uri; + if (!filePath) return; + + if ($target.closest(".cm-editor")) { + const view = editorManager.editor; + view.dispatch(view.state.replaceSelection(filePath)); + } else if ($target.isContentEditable) { + $target.textContent += filePath; + } else { + $target.value += filePath; + } } function isValidDropTabList($tabList) { From 38e3de78feb4a0934a8348fbd692487384cd5adf Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:16:30 +0530 Subject: [PATCH 12/20] fix tab cleanup --- src/handlers/editorFileTab.js | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index 81b091620..635b6a4c0 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -249,6 +249,12 @@ function finishDrag(shouldSettleClone) { if (shouldSettleClone) { const rect = $tab.getBoundingClientRect(); + let cleaned = false; + const safeCleanup = () => { + if (cleaned) return; + cleaned = true; + cleanupDrag(); + }; animate( $tabClone, { transform: `translate3d(${rect.left}px, ${rect.top}px, 0)` }, @@ -258,7 +264,10 @@ function finishDrag(shouldSettleClone) { : RELEASE_DURATION, ease: SPRING_EASING, }, - ).then(cleanupDrag); + ) + .then(safeCleanup) + .catch(safeCleanup); + setTimeout(safeCleanup, 500); return; } @@ -404,7 +413,9 @@ function updateParentMetrics() { function getDropTabList(clientX, clientY) { const $target = document.elementFromPoint(clientX, clientY); - const $tabList = $target?.closest?.(".editor-pane-tabs"); + const $tabList = + $target?.closest?.(".open-file-list") || + $target?.closest?.(".file-list > ul"); if (isValidDropTabList($tabList)) return $tabList; if (isFilePathDropTarget($target)) return null; @@ -442,8 +453,9 @@ function insertDraggedFilePath($target) { } function isValidDropTabList($tabList) { - if (!$tabList?.__editorPane) return false; + if (!$tabList) return false; if ($tabList === $originParent) return true; + if (!$tabList.__editorPane) return false; if (!allowPaneTransfer) return false; return $tabList.getClientRects().length > 0; } @@ -560,19 +572,19 @@ function updateFileList($parent) { editorManager.files = newFileList; - const draggedFile = newFileList.find((file) => file.tab === $tab); - if (draggedFile) { - const draggedIndex = newFileList.indexOf(draggedFile); + const localDraggedFile = newFileList.find((file) => file.tab === $tab); + if (localDraggedFile) { + const draggedIndex = newFileList.indexOf(localDraggedFile); let nextPinnedState; - if (!draggedFile.pinned && draggedIndex < pinnedCount) { + if (!localDraggedFile.pinned && draggedIndex < pinnedCount) { nextPinnedState = true; - } else if (draggedFile.pinned && draggedIndex >= pinnedCount) { + } else if (localDraggedFile.pinned && draggedIndex >= pinnedCount) { nextPinnedState = false; } if (nextPinnedState !== undefined) { - draggedFile.setPinnedState(nextPinnedState, { reorder: false }); + localDraggedFile.setPinnedState(nextPinnedState, { reorder: false }); if (typeof editorManager.normalizePinnedTabOrder === "function") { editorManager.normalizePinnedTabOrder(editorManager.files); } From 76035a53f5d39981f247dceebdf3b363559a2a44 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:13:20 +0530 Subject: [PATCH 13/20] added cleanup --- src/handlers/editorFileTab.js | 8 ++++-- src/lib/editorManager.js | 49 +++++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index 635b6a4c0..4c3de6e16 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -475,6 +475,7 @@ function animateTabReorder($parent, previousRects) { if (oldAnim) { oldAnim.cancel?.(); reorderAnimations.delete($child); + $child.style.transform = ""; } const previousRect = previousRects.get($child); @@ -503,11 +504,14 @@ function animateTabReorder($parent, previousRects) { ); reorderAnimations.set($child, anim); - anim.then(() => { + const cleanup = () => { if (reorderAnimations.get($child) === anim) { + $child.style.transform = ""; reorderAnimations.delete($child); } - }); + }; + + anim.then(cleanup).catch(cleanup); } } diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index d3f0e0503..81075a425 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -509,6 +509,17 @@ async function EditorManager($header, $body) { return node.children.flatMap((child) => getOrderedPanes(child)); } + function getVisiblePaneRect(pane) { + const element = pane?.element; + if (!element?.isConnected || element.getClientRects().length === 0) { + return null; + } + + const rect = element.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return null; + return rect; + } + function updatePaneLayoutState() { $paneRoot.classList.toggle("multi-pane", panes.length > 1); renderPaneLayout(); @@ -522,14 +533,21 @@ async function EditorManager($header, $body) { pane.element.style.opacity = "0"; pane.element.style.transform = "scale(0.985)"; + const element = pane.element; + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + element.style.opacity = ""; + element.style.transform = ""; + }; animate( - pane.element, + element, { opacity: 1, transform: "scale(1)" }, { type: "spring", stiffness: 360, damping: 32 }, - ).then(() => { - pane.element.style.opacity = ""; - pane.element.style.transform = ""; - }); + ) + .then(cleanup) + .catch(cleanup); } function startPaneResize(event, splitNode, childIndex, handle) { @@ -2353,15 +2371,30 @@ async function EditorManager($header, $body) { const active = getActivePane(); if (!active || panes.length <= 1) return false; - const activeRect = active.element.getBoundingClientRect(); + const orderedPanes = getOrderedPanes(); + const visiblePanes = orderedPanes + .map((pane) => ({ pane, rect: getVisiblePaneRect(pane) })) + .filter((entry) => entry.rect); + const activeEntry = visiblePanes.find((entry) => entry.pane === active); + + if (!activeEntry || visiblePanes.length <= 1) { + if (direction === "left" || direction === "up") { + return focusPaneByOffset(-1); + } + if (direction === "right" || direction === "down") { + return focusPaneByOffset(1); + } + return false; + } + + const activeRect = activeEntry.rect; const activeCenterX = activeRect.left + activeRect.width / 2; const activeCenterY = activeRect.top + activeRect.height / 2; let bestPane = null; let bestScore = Number.POSITIVE_INFINITY; - for (const pane of getOrderedPanes()) { + for (const { pane, rect } of visiblePanes) { if (pane === active) continue; - const rect = pane.element.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; let axisDistance = 0; From 140a706b7d842216311111a605c5942e60fa1f34 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:33:52 +0530 Subject: [PATCH 14/20] fix --- src/handlers/editorFileTab.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index 4c3de6e16..548c77d37 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -443,7 +443,9 @@ function insertDraggedFilePath($target) { if (!filePath) return; if ($target.closest(".cm-editor")) { - const view = editorManager.editor; + const view = + $target.closest(".editor-pane")?.__editorPane?.editor || + editorManager.editor; view.dispatch(view.state.replaceSelection(filePath)); } else if ($target.isContentEditable) { $target.textContent += filePath; From 2c78d6dadf7df4bed52f72adc46a74c1e32b3471 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:07:12 +0530 Subject: [PATCH 15/20] fixed bunch of issues introduced accidently --- src/components/sidebar/index.js | 7 +- src/lib/commands.js | 18 +++-- src/lib/editorFile.js | 76 ++++++++++++-------- src/lib/editorManager.js | 123 ++++++++++++++++++++++++-------- src/lib/fileList.js | 2 +- src/lib/showFileInfo.js | 11 +-- src/main.js | 14 ++-- 7 files changed, 177 insertions(+), 74 deletions(-) diff --git a/src/components/sidebar/index.js b/src/components/sidebar/index.js index 7920facfb..495601abb 100644 --- a/src/components/sidebar/index.js +++ b/src/components/sidebar/index.js @@ -644,7 +644,12 @@ function create($container, $toggler) { root.style.width = `calc(100% - ${width}px)`; clearTimeout(setWidthTimeout); setWidthTimeout = setTimeout(() => { - editorManager?.editor?.resize(true); + const editor = editorManager?.editor; + if (typeof editor?.resize === "function") { + editor.resize(true); + } else { + editor?.requestMeasure?.(); + } }, 300); } diff --git a/src/lib/commands.js b/src/lib/commands.js index dfdeea43f..c704008b0 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -238,7 +238,9 @@ export default { navigator.app.exitApp(); }, "edit-with"() { - editorManager.activeFile.editWith(); + const { activeFile } = editorManager; + if (!activeFile?.uri) return; + activeFile.editWith?.(); }, async "find-file"() { const { default: findFile } = await import( @@ -344,7 +346,9 @@ export default { editorManager.editor.contentDOM.blur(); }, "open-with"() { - editorManager.activeFile.openWith(); + const { activeFile } = editorManager; + if (!activeFile?.uri) return; + activeFile.openWith?.(); }, async "open-file"() { editorManager.editor.contentDOM.blur(); @@ -408,12 +412,13 @@ export default { browser.open(url); }, run() { - editorManager.activeFile[ + const { activeFile } = editorManager; + activeFile?.[ appSettings.value.useCurrentFileForPreview ? "runFile" : "run" ]?.(); }, "run-file"() { - editorManager.activeFile.runFile?.(); + editorManager.activeFile?.runFile?.(); }, async save(showToast) { try { @@ -443,7 +448,9 @@ export default { saveState(); }, share() { - editorManager.activeFile.share(); + const { activeFile } = editorManager; + if (!activeFile?.uri) return; + activeFile.share?.(); }, async "pin-file-shortcut"() { const file = editorManager.activeFile; @@ -637,6 +644,7 @@ export default { } }, async eol() { + if (editorManager.activeFile?.type !== "editor") return; const eol = await select(strings["new line mode"], ["unix", "windows"], { default: editorManager.activeFile.eol, }); diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 8e6c619ba..3629d6379 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -28,6 +28,44 @@ import run from "./run"; import saveFile from "./saveFile"; import appSettings from "./settings"; +function syncQuickToolsVisibility(file) { + const { $toggler } = quickTools; + const hideForFile = !!file?.hideQuickTools; + + clearTimeout($toggler._hideTimeout); + if (hideForFile || !appSettings.value.floatingButton) { + $toggler.classList.add("hide"); + $toggler._hideTimeout = setTimeout(() => { + $toggler.remove(); + $toggler._hideTimeout = null; + }, 300); + } else { + $toggler._hideTimeout = null; + $toggler.classList.remove("hide"); + if (!$toggler.isConnected) { + root.appendOuter($toggler); + } + } + + if (hideForFile) { + actions("set-height", { height: 0, save: false }); + return; + } + + const quickToolsHeight = + appSettings.value.quickTools !== undefined + ? appSettings.value.quickTools + : 1; + actions("set-height", { height: quickToolsHeight, save: false }); +} + +function isTouchDevice() { + return ( + typeof navigator !== "undefined" && + Number(navigator.maxTouchPoints || 0) > 0 + ); +} + /** * Creates a Proxy around an EditorState that provides Ace-compatible methods. * @param {EditorState} state - The raw CodeMirror EditorState @@ -1374,6 +1412,10 @@ export default class EditorFile { const wasActivePane = editorManager.activePane?.id === pane?.id; const { activeFile, switchFile } = editorManager; const paneActiveFile = pane?.activeFile; + const activeEditor = editorManager.editor; + const editorHadDomFocus = + activeEditor?.contentDOM === document.activeElement || + activeEditor?.contentDOM?.contains(document.activeElement); const inactiveFiles = [paneActiveFile, !wasActivePane ? activeFile : null]; const blurredFileIds = new Set(); @@ -1384,7 +1426,10 @@ export default class EditorFile { blurredFileIds.add(file.id); } - if (activeFile?.id === this.id && wasActivePane) return; + if (activeFile?.id === this.id && wasActivePane) { + syncQuickToolsVisibility(this); + return; + } switchFile(this.id, pane); @@ -1393,7 +1438,7 @@ export default class EditorFile { // Show/hide appropriate content if (this.type === "editor") { editorManager.container.style.display = "block"; - if (this.focused) { + if (this.focused && editorHadDomFocus && !isTouchDevice()) { editor.focus(); } else { editor.contentDOM.blur(); @@ -1427,32 +1472,7 @@ export default class EditorFile { this.#loadText(); } - // Handle quicktools visibility based on hideQuickTools property - if (this.hideQuickTools) { - const { $toggler } = quickTools; - clearTimeout($toggler._hideTimeout); - $toggler.classList.add("hide"); - $toggler._hideTimeout = setTimeout(() => { - $toggler.remove(); - $toggler._hideTimeout = null; - }, 300); - actions("set-height", { height: 0, save: false }); - } else { - const { $toggler } = quickTools; - if (appSettings.value.floatingButton) { - clearTimeout($toggler._hideTimeout); - $toggler._hideTimeout = null; - $toggler.classList.remove("hide"); - if (!$toggler.isConnected) { - root.appendOuter($toggler); - } - } - const quickToolsHeight = - appSettings.value.quickTools !== undefined - ? appSettings.value.quickTools - : 1; - actions("set-height", { height: quickToolsHeight, save: false }); - } + syncQuickToolsVisibility(this); editorManager.header.subText = this.#getTitle(); diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 81075a425..c683953a9 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -317,6 +317,7 @@ async function EditorManager($header, $body) { files: [], activeFile: null, editor: null, + cleanupEditorListeners: null, editorContainer, touchSelectionController: null, element:
        , @@ -336,7 +337,7 @@ async function EditorManager($header, $body) { pane.element.addEventListener( "pointerdown", () => { - activatePane(pane); + activatePane(pane, { focusEditor: false }); }, true, ); @@ -727,6 +728,39 @@ async function EditorManager($header, $body) { if (!appSettings.value.shiftClickSelection) return false; return !!event?.shiftKey || quickTools?.$footer?.dataset?.shift != null; }; + + function registerSoftKeyboardCursorReveal() { + const shouldRevealCursor = () => { + const view = editor; + if (!view || manager?.activeFile?.type !== "editor") return false; + const activeElement = document.activeElement; + return ( + view.contentDOM === activeElement || + view.contentDOM.contains(activeElement) + ); + }; + + keyboardHandler.on("keyboardShowStart", () => { + requestAnimationFrame(() => { + if (!shouldRevealCursor()) return; + if (isCursorRevealSuppressed()) return; + scrollCursorIntoView({ behavior: "instant" }); + }); + }); + keyboardHandler.on("keyboardShow", () => { + if (!shouldRevealCursor()) return; + if (isCursorRevealSuppressed()) return; + scrollCursorIntoView(); + }); + keyboardHandler.on("keyboardHide", () => { + requestAnimationFrame(() => { + if (!shouldRevealCursor()) return; + if (isCursorRevealSuppressed()) return; + scrollCursorIntoView({ behavior: "instant" }); + }); + }); + } + const shiftClickSelectionExtension = EditorView.domEventHandlers({ click(event) { if (!touchSelectionController?.consumePendingShiftSelectionClick(event)) { @@ -2272,7 +2306,6 @@ async function EditorManager($header, $body) { createUntitledPaneFile(pane); } else if (options.activate !== false) { setActivePane(pane); - pane.editor?.focus(); } return pane; @@ -2328,7 +2361,10 @@ async function EditorManager($header, $body) { pane.touchSelectionController?.destroy?.(); pane.touchSelectionController = null; + pane.cleanupEditorListeners?.(); + pane.cleanupEditorListeners = null; pane.editor?.destroy?.(); + pane.editor = null; removePaneFromLayout(pane); const storedPaneIndex = panes.indexOf(pane); if (storedPaneIndex >= 0) panes.splice(storedPaneIndex, 1); @@ -2340,7 +2376,7 @@ async function EditorManager($header, $body) { if (fileToActivate) { fileToActivate.makeActive(); } else { - activatePane(targetPane); + activatePane(targetPane, { focusEditor: false }); } syncOpenFileList(); return true; @@ -2355,7 +2391,7 @@ async function EditorManager($header, $body) { (index + offset + orderedPanes.length) % orderedPanes.length ]; if (!nextPane) return false; - activatePane(nextPane); + activatePane(nextPane, { focusEditor: false }); return true; } @@ -2428,7 +2464,7 @@ async function EditorManager($header, $body) { } if (!bestPane) return false; - activatePane(bestPane); + activatePane(bestPane, { focusEditor: false }); return true; } @@ -3103,6 +3139,7 @@ async function EditorManager($header, $body) { $body.append($paneRoot); initModes(); // Initialize CodeMirror modes + registerSoftKeyboardCursorReveal(); await setupEditor(primaryPane); // Initialize theme from settings or fallback @@ -3819,6 +3856,9 @@ async function EditorManager($header, $body) { let scrollTimeout; let scrollSyncRaf = 0; const scroller = editor.scrollDOM; + let pendingKeyboardHideBlur = null; + pane.cleanupEditorListeners?.(); + pane.cleanupEditorListeners = null; function syncScrollUi() { if (pane !== activePane) return; @@ -3862,23 +3902,6 @@ async function EditorManager($header, $body) { }); syncScrollUi(); - keyboardHandler.on("keyboardShowStart", () => { - requestAnimationFrame(() => { - if (isCursorRevealSuppressed()) return; - scrollCursorIntoView({ behavior: "instant" }); - }); - }); - keyboardHandler.on("keyboardShow", () => { - if (isCursorRevealSuppressed()) return; - scrollCursorIntoView(); - }); - keyboardHandler.on("keyboardHide", () => { - requestAnimationFrame(() => { - if (isCursorRevealSuppressed()) return; - scrollCursorIntoView({ behavior: "instant" }); - }); - }); - // Attach native DOM event listeners directly to the editor's contentDOM const contentDOM = editor.contentDOM; const isFocused = @@ -3886,7 +3909,7 @@ async function EditorManager($header, $body) { contentDOM.contains(document.activeElement); setNativeContextMenuDisabled(isFocused); - contentDOM.addEventListener("focus", (_event) => { + function handleContentFocus(_event) { setActivePane(pane); setNativeContextMenuDisabled(true); const activeFile = pane.activeFile; @@ -3894,9 +3917,9 @@ async function EditorManager($header, $body) { activeFile.focused = true; } touchSelectionController?.onStateChanged(); - }); + } - contentDOM.addEventListener("blur", async (_event) => { + async function handleContentBlur(_event) { setNativeContextMenuDisabled(false); touchSelectionController?.setMenu(false); const { hardKeyboardHidden, keyboardHeight } = @@ -3919,16 +3942,46 @@ async function EditorManager($header, $body) { // soft keyboard - wait for keyboard to hide const onKeyboardHide = () => { keyboardHandler.off("keyboardHide", onKeyboardHide); + if (pendingKeyboardHideBlur === onKeyboardHide) { + pendingKeyboardHideBlur = null; + } blur(); }; + if (pendingKeyboardHideBlur) { + keyboardHandler.off("keyboardHide", pendingKeyboardHideBlur); + } + pendingKeyboardHideBlur = onKeyboardHide; keyboardHandler.on("keyboardHide", onKeyboardHide); - }); + } - contentDOM.addEventListener("keydown", (event) => { + function handleContentKeydown(event) { if (event.key === "Escape") { keydownState.esc = { value: true, target: contentDOM }; } - }); + } + + contentDOM.addEventListener("focus", handleContentFocus); + contentDOM.addEventListener("blur", handleContentBlur); + contentDOM.addEventListener("keydown", handleContentKeydown); + + pane.cleanupEditorListeners = () => { + scroller?.removeEventListener("scroll", handleEditorScroll); + scroller?.removeEventListener("pointerdown", clearScrollbarScrollLock); + scroller?.removeEventListener("touchstart", clearScrollbarScrollLock); + scroller?.removeEventListener("wheel", clearScrollbarScrollLock); + contentDOM.removeEventListener("focus", handleContentFocus); + contentDOM.removeEventListener("blur", handleContentBlur); + contentDOM.removeEventListener("keydown", handleContentKeydown); + clearTimeout(scrollTimeout); + if (scrollSyncRaf) { + cancelAnimationFrame(scrollSyncRaf); + scrollSyncRaf = 0; + } + if (pendingKeyboardHideBlur) { + keyboardHandler.off("keyboardHide", pendingKeyboardHideBlur); + pendingKeyboardHideBlur = null; + } + }; updateMargin(true); updateSideButtonContainer(); @@ -4391,10 +4444,20 @@ async function EditorManager($header, $body) { const pane = targetPane || getFilePane(id) || getActivePane(); if (!pane) return; const paneActiveFile = pane.activeFile; - if (paneActiveFile?.id === id && activePane === pane) return; - const file = manager.getFile(id); if (!file) return; + if (paneActiveFile?.id === id && activePane === pane) { + if (manager.activeFile?.id !== id) { + manager.activeFile = file; + file.tab?.classList.add("active"); + updateHeaderForFile(file); + if (isPaneTabLayout()) syncGlobalOpenFileListMirror(); + manager.onupdate("switch-file"); + events.emit("switch-file", file); + toggleProblemButton(); + } + return; + } setActivePane(pane, { emitSwitch: false }); diff --git a/src/lib/fileList.js b/src/lib/fileList.js index b57b062fe..39f4807de 100644 --- a/src/lib/fileList.js +++ b/src/lib/fileList.js @@ -21,7 +21,7 @@ const events = { }; export function initFileList() { - if (editorManager?.activeFile.loading) { + if (editorManager?.activeFile?.loading) { editorManager.activeFile.on("loadend", initFileList); return; } diff --git a/src/lib/showFileInfo.js b/src/lib/showFileInfo.js index 9b0650c3c..1520f7c3c 100644 --- a/src/lib/showFileInfo.js +++ b/src/lib/showFileInfo.js @@ -13,7 +13,9 @@ import settings from "./settings"; * @param {String} [url] */ export default async function showFileInfo(url) { - if (!url) url = editorManager.activeFile.uri; + const activeFile = editorManager.activeFile; + if (!url) url = activeFile?.uri; + if (!url) return; loader.showTitleLoader(); try { const fs = fsOperation(url); @@ -24,7 +26,7 @@ export default async function showFileInfo(url) { lastModified = new Date(lastModified).toLocaleString(); const protocol = Url.getProtocol(url); - const fileType = type.toLowerCase(); + const fileType = String(type || "").toLowerCase(); const options = { name: name.slice(0, name.length - Url.extname(name).length), extension: Url.extname(name), @@ -34,10 +36,11 @@ export default async function showFileInfo(url) { lang: strings, showUri: helpers.getVirtualPath(url), isEditor: - fileType === "text/plain" || editorManager.activeFile.type === "editor", + fileType === "text/plain" || + (activeFile?.uri === url && activeFile.type === "editor"), }; - if (editorManager.activeFile.type === "editor") { + if (options.isEditor) { const value = await fs.readFile(settings.value.defaultFileEncoding); options.lineCount = value.split(/\n+/).length; options.wordCount = value.split(/\s+|\n+/).length; diff --git a/src/main.js b/src/main.js index 08c64a618..efe610526 100644 --- a/src/main.js +++ b/src/main.js @@ -725,12 +725,16 @@ async function loadApp() { // if (!$editMenuToggler.isConnected) { // $header.insertBefore($editMenuToggler, $header.lastChild); // } - if (activeFile?.type === "page" || activeFile?.type === "terminal") { - $editMenuToggler.remove(); - } else { + if ( + activeFile && + activeFile.type !== "page" && + activeFile.type !== "terminal" + ) { if (!$editMenuToggler.isConnected) { $header.insertBefore($editMenuToggler, $header.lastChild); } + } else { + $editMenuToggler.remove(); } if (mode === "switch-file") { @@ -825,9 +829,9 @@ function createFileMenu({ top, bottom, toggler }) { toggler, transformOrigin: top ? "top right" : "bottom right", innerHTML: () => { - const file = window.editorManager.activeFile; + const file = window.editorManager?.activeFile; - if (file.type === "page") { + if (!file || file.type === "page" || file.type === "terminal") { return ""; } From 32b41d35a2d6c88400f7eb1147ff9e0b19337146 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:18:19 +0530 Subject: [PATCH 16/20] remove double doc sync and add Ctrl-touch for multi cursor too --- src/cm/touchSelectionMenu.js | 112 ++++++++++++++++++++++++++++++++++- src/lib/editorManager.js | 59 +++++++++--------- 2 files changed, 139 insertions(+), 32 deletions(-) diff --git a/src/cm/touchSelectionMenu.js b/src/cm/touchSelectionMenu.js index 44a9e761c..390a81378 100644 --- a/src/cm/touchSelectionMenu.js +++ b/src/cm/touchSelectionMenu.js @@ -128,10 +128,12 @@ class TouchSelectionMenuController { #container; #getActiveFile; #isShiftSelectionActive; + #isCtrlSelectionActive; #stateSyncRaf = 0; #isScrolling = false; #isPointerInteracting = false; #shiftSelectionSession = null; + #ctrlSelectionSession = null; #pendingShiftSelectionClick = null; #menuActive = false; #menuRequested = false; @@ -147,6 +149,8 @@ class TouchSelectionMenuController { this.#getActiveFile = options.getActiveFile || (() => null); this.#isShiftSelectionActive = options.isShiftSelectionActive || (() => false); + this.#isCtrlSelectionActive = + options.isCtrlSelectionActive || (() => false); this.$menu = document.createElement("menu"); this.$menu.className = "cursor-menu"; this.#bindEvents(); @@ -196,6 +200,7 @@ class TouchSelectionMenuController { cancelAnimationFrame(this.#stateSyncRaf); this.#stateSyncRaf = 0; this.#shiftSelectionSession = null; + this.#ctrlSelectionSession = null; this.#pendingShiftSelectionClick = null; this.#tooltipObserver?.disconnect(); this.#hideMenu(true); @@ -205,6 +210,7 @@ class TouchSelectionMenuController { this.#enabled = !!enabled; if (this.#enabled) return; this.#shiftSelectionSession = null; + this.#ctrlSelectionSession = null; this.#pendingShiftSelectionClick = null; this.#menuRequested = false; this.#isPointerInteracting = false; @@ -274,6 +280,7 @@ class TouchSelectionMenuController { onSessionChanged() { if (!this.#enabled) return; this.#shiftSelectionSession = null; + this.#ctrlSelectionSession = null; this.#pendingShiftSelectionClick = null; this.#menuRequested = false; this.#isPointerInteracting = false; @@ -296,15 +303,19 @@ class TouchSelectionMenuController { if (this.$menu.contains(target)) return; if (this.#isIgnoredPointerTarget(target)) { this.#shiftSelectionSession = null; + this.#ctrlSelectionSession = null; return; } if (target instanceof Node && this.#view.dom.contains(target)) { - this.#captureShiftSelection(event); + if (!this.#captureCtrlSelection(event)) { + this.#captureShiftSelection(event); + } this.#isPointerInteracting = true; this.#clearMenuShowTimer(); return; } this.#shiftSelectionSession = null; + this.#ctrlSelectionSession = null; this.#isPointerInteracting = false; this.#menuRequested = false; this.#hideMenu(); @@ -312,9 +323,12 @@ class TouchSelectionMenuController { #onGlobalPointerUp = (event) => { if (event.type === "pointerup") { - this.#commitShiftSelection(event); + if (!this.#commitCtrlSelection(event)) { + this.#commitShiftSelection(event); + } } else { this.#shiftSelectionSession = null; + this.#ctrlSelectionSession = null; } if (!this.#isPointerInteracting) return; this.#isPointerInteracting = false; @@ -330,6 +344,7 @@ class TouchSelectionMenuController { }; #captureShiftSelection(event) { + this.#ctrlSelectionSession = null; if (!this.#canExtendSelection(event)) { this.#shiftSelectionSession = null; return; @@ -377,6 +392,92 @@ class TouchSelectionMenuController { event.preventDefault(); } + #captureCtrlSelection(event) { + this.#shiftSelectionSession = null; + if (!this.#canAddCursor(event)) { + this.#ctrlSelectionSession = null; + return false; + } + + this.#ctrlSelectionSession = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }; + event.preventDefault(); + event.stopPropagation(); + return true; + } + + #commitCtrlSelection(event) { + const session = this.#ctrlSelectionSession; + this.#ctrlSelectionSession = null; + if (!session) return false; + if (!this.#canAddCursor(event)) return false; + if (event.pointerId !== session.pointerId) return false; + if ( + Math.hypot(event.clientX - session.x, event.clientY - session.y) > + TAP_MAX_DISTANCE + ) { + return false; + } + const target = event.target; + if (!(target instanceof Node) || !this.#view.dom.contains(target)) { + return false; + } + if (this.#isIgnoredPointerTarget(target)) return false; + + const pos = this.#view.posAtCoords( + { x: event.clientX, y: event.clientY }, + false, + ); + if (pos == null) return false; + + const selection = this.#view.state.selection; + const ranges = selection.ranges; + const existingIndex = ranges.findIndex( + (range) => range.from <= pos && range.to >= pos, + ); + let nextRanges; + let mainIndex; + + if (existingIndex >= 0) { + if (ranges.length <= 1) { + this.#pendingShiftSelectionClick = { + x: event.clientX, + y: event.clientY, + timeStamp: event.timeStamp, + }; + event.preventDefault(); + event.stopPropagation(); + return true; + } + nextRanges = ranges.filter((_, index) => index !== existingIndex); + mainIndex = Math.min(selection.mainIndex, nextRanges.length - 1); + } else { + const cursor = EditorSelection.cursor(pos); + const insertAt = ranges.findIndex( + (range) => range.from > pos || (range.from === pos && range.to >= pos), + ); + mainIndex = insertAt === -1 ? ranges.length : insertAt; + nextRanges = ranges.slice(); + nextRanges.splice(mainIndex, 0, cursor); + } + + this.#view.dispatch({ + selection: EditorSelection.create(nextRanges, mainIndex), + userEvent: "select.pointer", + }); + this.#pendingShiftSelectionClick = { + x: event.clientX, + y: event.clientY, + timeStamp: event.timeStamp, + }; + event.preventDefault(); + event.stopPropagation(); + return true; + } + #canExtendSelection(event) { if (!this.#enabled) return false; if (!(event.isTrusted && event.isPrimary)) return false; @@ -384,6 +485,13 @@ class TouchSelectionMenuController { return !!this.#isShiftSelectionActive(event); } + #canAddCursor(event) { + if (!this.#enabled) return false; + if (!(event.isTrusted && event.isPrimary)) return false; + if (typeof event.button === "number" && event.button !== 0) return false; + return !!this.#isCtrlSelectionActive(event); + } + consumePendingShiftSelectionClick(event) { const pending = this.#pendingShiftSelectionClick; this.#pendingShiftSelectionClick = null; diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index c683953a9..02873d178 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -1,7 +1,7 @@ import sidebarApps from "sidebarApps"; import { indentUnit, language as languageFacet } from "@codemirror/language"; import { search } from "@codemirror/search"; -import { Compartment, EditorState, Prec, StateEffect } from "@codemirror/state"; +import { Compartment, EditorState, Prec } from "@codemirror/state"; import { oneDark } from "@codemirror/theme-one-dark"; import { closeHoverTooltips, @@ -311,7 +311,10 @@ async function EditorManager($header, $body) { }); } - function createPaneShell(editorContainer = createEditorContainer()) { + function createPaneShell( + editorContainer = createEditorContainer(), + registerPane = true, + ) { const pane = { id: `pane-${++paneIdCounter}`, files: [], @@ -341,7 +344,7 @@ async function EditorManager($header, $body) { }, true, ); - panes.push(pane); + if (registerPane) panes.push(pane); return pane; } @@ -728,6 +731,9 @@ async function EditorManager($header, $body) { if (!appSettings.value.shiftClickSelection) return false; return !!event?.shiftKey || quickTools?.$footer?.dataset?.shift != null; }; + const isCtrlSelectionActive = (event) => { + return !!event?.ctrlKey || quickTools?.$footer?.dataset?.ctrl != null; + }; function registerSoftKeyboardCursorReveal() { const shouldRevealCursor = () => { @@ -1574,6 +1580,7 @@ async function EditorManager($header, $body) { container: $container, getActiveFile: () => manager?.activeFile || null, isShiftSelectionActive, + isCtrlSelectionActive, }); primaryPane.touchSelectionController = touchSelectionController; @@ -2265,18 +2272,8 @@ async function EditorManager($header, $body) { container: pane.editorContainer, getActiveFile: () => pane.activeFile || null, isShiftSelectionActive, + isCtrlSelectionActive, }); - try { - paneEditor.dispatch({ - effects: StateEffect.appendConfig.of(getDocSyncListener()), - }); - } catch (error) { - warnRecoverable( - "Failed to attach document sync listener to split editor.", - error, - `doc-sync-listener-${pane.id}`, - ); - } await setupEditor(pane); return paneEditor; } @@ -2292,12 +2289,27 @@ async function EditorManager($header, $body) { return null; } - const pane = createPaneShell(); + const pane = createPaneShell(undefined, false); + try { + await createPaneEditor(pane); + } catch (error) { + pane.touchSelectionController?.destroy?.(); + pane.touchSelectionController = null; + pane.editor?.destroy?.(); + pane.editor = null; + warnRecoverable( + "Failed to create split editor pane.", + error, + `create-pane-editor-${pane.id}`, + ); + window.toast?.(strings.error || "Error"); + return null; + } + panes.push(pane); insertPaneIntoLayout(sourcePane, pane, direction); updatePaneLayoutState(); animatePaneEntry(pane); - await createPaneEditor(pane); - updatePaneLayoutState(); + pane.editor?.requestMeasure?.(); syncOpenFileList(); if (options.moveFile) { @@ -3468,19 +3480,6 @@ async function EditorManager($header, $body) { } }); - // Attach doc-sync listener to the current editor instance - try { - editor.dispatch({ - effects: StateEffect.appendConfig.of(getDocSyncListener()), - }); - } catch (error) { - warnRecoverable( - "Failed to attach document sync listener to editor.", - error, - "doc-sync-listener", - ); - } - return manager; /** From 7cb916fd05c9640b0c0417a6a569b89e133b3cfb Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:04:32 +0530 Subject: [PATCH 17/20] correctly drop at carret --- src/handlers/editorFileTab.js | 150 +++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 4 deletions(-) diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index 548c77d37..159b96d03 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -223,7 +223,7 @@ function releaseDrag(e) { updateFileList($parent); } } else if (isPathDropTarget) { - insertDraggedFilePath($target); + insertDraggedFilePath($target, clientX, clientY); } const shouldSettleClone = shouldCommitReorder || didReorder; @@ -438,7 +438,7 @@ function isFilePathDropTarget($target) { ); } -function insertDraggedFilePath($target) { +function insertDraggedFilePath($target, clientX, clientY) { const filePath = draggedFile?.uri || editorManager.activeFile?.uri; if (!filePath) return; @@ -448,12 +448,154 @@ function insertDraggedFilePath($target) { editorManager.editor; view.dispatch(view.state.replaceSelection(filePath)); } else if ($target.isContentEditable) { - $target.textContent += filePath; + insertTextIntoContentEditable($target, filePath, clientX, clientY); } else { - $target.value += filePath; + insertTextIntoInput($target, filePath); } } +function insertTextIntoContentEditable($target, text, clientX, clientY) { + const $editable = getContentEditableRoot($target); + const range = getContentEditableRange($editable, clientX, clientY); + const selection = document.getSelection(); + + if (!range || !selection) { + $editable.append(document.createTextNode(text)); + $editable.dispatchEvent(createInputEvent(text)); + return; + } + + focusWithoutScrolling($editable); + selection.removeAllRanges(); + selection.addRange(range); + + if ( + typeof document.execCommand === "function" && + document.queryCommandSupported?.("insertText") + ) { + const inserted = document.execCommand("insertText", false, text); + if (inserted) return; + } + + range.deleteContents(); + const textNode = document.createTextNode(text); + range.insertNode(textNode); + range.setStartAfter(textNode); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + $editable.dispatchEvent(createInputEvent(text)); +} + +function getContentEditableRoot($target) { + let $editable = $target; + while ($editable.parentElement?.isContentEditable) { + $editable = $editable.parentElement; + } + + return $editable; +} + +function focusWithoutScrolling($target) { + try { + $target.focus({ preventScroll: true }); + } catch { + $target.focus(); + } +} + +function getContentEditableRange($target, clientX, clientY) { + const range = getCaretRangeFromPoint(clientX, clientY); + if (range && isRangeInsideElement(range, $target)) return range; + + const selection = document.getSelection(); + if (selection?.rangeCount) { + const selectionRange = selection.getRangeAt(0); + if (isRangeInsideElement(selectionRange, $target)) { + return selectionRange.cloneRange(); + } + } + + const fallbackRange = document.createRange(); + fallbackRange.selectNodeContents($target); + fallbackRange.collapse(false); + return fallbackRange; +} + +function getCaretRangeFromPoint(clientX, clientY) { + const caretRange = document.caretRangeFromPoint?.(clientX, clientY); + if (caretRange) return caretRange; + + const caretPosition = document.caretPositionFromPoint?.(clientX, clientY); + if (!caretPosition) return null; + + const range = document.createRange(); + range.setStart(caretPosition.offsetNode, caretPosition.offset); + range.collapse(true); + return range; +} + +function isRangeInsideElement(range, $element) { + const { commonAncestorContainer } = range; + return ( + commonAncestorContainer === $element || + $element.contains(commonAncestorContainer) + ); +} + +function insertTextIntoInput($target, text) { + const value = $target.value || ""; + const start = getInputSelectionOffset( + $target, + "selectionStart", + value.length, + ); + const end = getInputSelectionOffset($target, "selectionEnd", start); + + if (typeof $target.setRangeText === "function") { + try { + $target.setRangeText(text, start, end, "end"); + } catch { + $target.value = `${value}${text}`; + } + } else { + const nextValue = `${value.slice(0, start)}${text}${value.slice(end)}`; + $target.value = nextValue; + setInputSelectionOffset($target, start + text.length); + } + + $target.dispatchEvent(createInputEvent(text)); +} + +function getInputSelectionOffset($target, key, fallback) { + try { + return typeof $target[key] === "number" ? $target[key] : fallback; + } catch { + return fallback; + } +} + +function setInputSelectionOffset($target, offset) { + try { + $target.selectionStart = $target.selectionEnd = offset; + } catch { + return; + } +} + +function createInputEvent(text) { + if (typeof InputEvent === "function") { + return new InputEvent("input", { + bubbles: true, + cancelable: false, + data: text, + inputType: "insertText", + }); + } + + return new Event("input", { bubbles: true, cancelable: false }); +} + function isValidDropTabList($tabList) { if (!$tabList) return false; if ($tabList === $originParent) return true; From a55de81a9a327e8b0973fae9b2cc7c5c686679d0 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:32:24 +0530 Subject: [PATCH 18/20] fix and use doc cache instead of direct doc.toString --- src/cm/editorUtils.ts | 18 ++++ src/cm/lsp/references.ts | 3 +- src/lib/editorFile.js | 27 ++++-- src/lib/editorManager.js | 124 +++++++++++++++++++++++-- src/lib/prettierFormatter.js | 3 +- src/lib/run.js | 13 +-- src/lib/saveFile.js | 3 +- src/pages/markdownPreview/index.js | 3 +- src/sidebarApps/searchInFiles/index.js | 5 +- 9 files changed, 172 insertions(+), 27 deletions(-) diff --git a/src/cm/editorUtils.ts b/src/cm/editorUtils.ts index ff750f061..a8f2569e6 100644 --- a/src/cm/editorUtils.ts +++ b/src/cm/editorUtils.ts @@ -22,6 +22,24 @@ export interface ScrollPosition { scrollLeft: number; } +const docTextCache = new WeakMap(); + +/** + * CodeMirror Text documents are immutable, so the full string can be cached + * per document object and reused across compatibility/plugin reads. + */ +export function getDocText( + doc: { toString(): string } | string | null | undefined, +): string { + if (!doc) return ""; + if (typeof doc !== "object" && typeof doc !== "function") return String(doc); + const cached = docTextCache.get(doc); + if (cached !== undefined) return cached; + const text = doc.toString(); + docTextCache.set(doc, text); + return text; +} + /** * Get all folded ranges from CodeMirror editor state */ diff --git a/src/cm/lsp/references.ts b/src/cm/lsp/references.ts index 53bf8e7cf..79bc31cc6 100644 --- a/src/cm/lsp/references.ts +++ b/src/cm/lsp/references.ts @@ -5,6 +5,7 @@ import { openReferencesTab, showReferencesPanel, } from "components/referencesPanel"; +import { getDocText } from "cm/editorUtils"; import settings from "lib/settings"; interface Position { @@ -61,7 +62,7 @@ async function fetchLineText(uri: string, line: number): Promise { } } if (typeof doc.toString === "function") { - const content = doc.toString(); + const content = getDocText(doc as { toString(): string }); const lines = content.split("\n"); if (lines[line] !== undefined) { return lines[line]; diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 3629d6379..99a50ab6f 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -3,6 +3,7 @@ import fsOperation from "fileSystem"; import { EditorState } from "@codemirror/state"; import { clearSelection, + getDocText, restoreFolds, restoreSelection, setScrollPosition, @@ -120,7 +121,7 @@ function createSessionProxy(state, file) { // Ace-compatible method: getValue() if (prop === "getValue") { - return () => target.doc.toString(); + return () => getDocText(target.doc); } // Ace-compatible method: setValue(text) @@ -854,7 +855,7 @@ export default class EditorFile { set eol(value) { if (this.type !== "editor") return; if (this.eol === value) return; - let text = this.session.doc.toString(); + let text = getDocText(this.session.doc); if (value === "windows") { text = text.replace(/\n(? { @@ -1248,8 +1258,9 @@ export default class EditorFile { silentPinned = false, suppressPanePlaceholder = false, } = options || {}; + const isUnsaved = this.refreshUnsavedState(); const suppressFallback = - suppressPanePlaceholder && this.isPanePlaceholder && !this.isUnsaved; + suppressPanePlaceholder && this.isPanePlaceholder && !isUnsaved; if (this.id === config.DEFAULT_FILE_SESSION && !editorManager.files.length) return false; @@ -1262,7 +1273,7 @@ export default class EditorFile { } return false; } - if (!force && this.isUnsaved) { + if (!force && isUnsaved) { const confirmation = await confirm( strings.warning.toUpperCase(), strings["unsaved file"], diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 02873d178..c9eb37843 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -61,6 +61,7 @@ import { serverCompletionSource } from "@codemirror/lsp-client"; import colorView from "cm/colorView"; import { getAllFolds, + getDocText, restoreFolds, restoreSelection, setScrollPosition, @@ -219,6 +220,12 @@ async function EditorManager($header, $body) { replaceChildren: $globalOpenFileList.replaceChildren.bind($globalOpenFileList), }; + let globalOpenFileListMirrorFiles = null; + let globalOpenFileListMirrorActiveFileId = ""; + const globalOpenFileListMirrorTabs = new Map(); + const globalOpenFileListMirrorTabsById = new Map(); + const globalOpenFileListMirrorTabSignatures = new Map(); + const globalOpenFileListMirrorDirtyFiles = new Set(); const $paneAwareOpenFileList = createPaneAwareOpenFileListProxy($globalOpenFileList); let $container = createEditorContainer(); @@ -1804,7 +1811,7 @@ async function EditorManager($header, $body) { */ editor.getValue = function () { try { - return editor.state.doc.toString(); + return getDocText(editor.state.doc); } catch (_) { return ""; } @@ -2127,7 +2134,7 @@ async function EditorManager($header, $body) { writable: true, value() { try { - return getDoc().toString(); + return getDocText(getDoc()); } catch (_) { return ""; } @@ -3401,6 +3408,7 @@ async function EditorManager($header, $body) { timers.checkTimeout = setTimeout(async () => { timers.checkTimeout = null; + file.refreshUnsavedState?.(); try { file.scheduleCacheWrite(); } catch (error) { @@ -3446,6 +3454,11 @@ async function EditorManager($header, $body) { } }); + manager.on( + ["file-content-changed", "rename-file", "save-file", "update:pin-tab"], + markGlobalOpenFileListMirrorDirty, + ); + manager.on(["update:read-only"], () => { const file = manager.activeFile; if (file?.type !== "editor") return; @@ -3592,15 +3605,110 @@ async function EditorManager($header, $body) { } function syncGlobalOpenFileListMirror() { + const shouldRebuild = + globalOpenFileListMirrorFiles !== manager.files || + $globalOpenFileList.childElementCount !== manager.files.length; + + if (shouldRebuild) { + rebuildGlobalOpenFileListMirror(); + return; + } + + if (!globalOpenFileListMirrorDirtyFiles.size) { + syncGlobalOpenFileListMirrorActiveState(); + return; + } + + const dirtyFiles = [...globalOpenFileListMirrorDirtyFiles]; + const fileIndexes = new Map( + manager.files.map((file, index) => [file, index]), + ); + globalOpenFileListMirrorDirtyFiles.clear(); + + dirtyFiles.forEach((file) => { + const index = fileIndexes.get(file); + if (index === undefined) return; + const signature = getGlobalOpenFileListMirrorTabSignature(file); + if (globalOpenFileListMirrorTabSignatures.get(file) === signature) { + return; + } + + const nextTab = createGlobalOpenFileListMirrorTab(file); + const currentTab = + globalOpenFileListMirrorTabs.get(file) || + $globalOpenFileList.children[index]; + if (currentTab?.parentElement === $globalOpenFileList) { + globalOpenFileListNative.insertBefore(nextTab, currentTab); + currentTab.remove(); + } else { + globalOpenFileListNative.insertBefore( + nextTab, + $globalOpenFileList.children[index] || null, + ); + } + cacheGlobalOpenFileListMirrorTab(file, nextTab, signature); + }); + + syncGlobalOpenFileListMirrorActiveState(); + } + + function rebuildGlobalOpenFileListMirror() { + globalOpenFileListMirrorTabs.clear(); + globalOpenFileListMirrorTabsById.clear(); + globalOpenFileListMirrorTabSignatures.clear(); + globalOpenFileListMirrorDirtyFiles.clear(); + globalOpenFileListNative.replaceChildren( ...manager.files.map((file) => { - const tab = file.tab.cloneNode(true); - tab.dataset.fileId = file.id; - tab.dataset.paneId = file.paneId || ""; - tab.classList.toggle("active", manager.activeFile?.id === file.id); + const tab = createGlobalOpenFileListMirrorTab(file); + cacheGlobalOpenFileListMirrorTab( + file, + tab, + getGlobalOpenFileListMirrorTabSignature(file), + ); return tab; }), ); + + globalOpenFileListMirrorFiles = manager.files; + globalOpenFileListMirrorActiveFileId = manager.activeFile?.id || ""; + } + + function createGlobalOpenFileListMirrorTab(file) { + const tab = file.tab.cloneNode(true); + tab.dataset.fileId = file.id; + tab.dataset.paneId = file.paneId || ""; + tab.classList.toggle("active", manager.activeFile?.id === file.id); + return tab; + } + + function cacheGlobalOpenFileListMirrorTab(file, tab, signature) { + globalOpenFileListMirrorTabs.set(file, tab); + globalOpenFileListMirrorTabsById.set(file.id, tab); + globalOpenFileListMirrorTabSignatures.set(file, signature); + } + + function markGlobalOpenFileListMirrorDirty(file) { + if (file) globalOpenFileListMirrorDirtyFiles.add(file); + } + + function syncGlobalOpenFileListMirrorActiveState() { + const activeFileId = manager.activeFile?.id || ""; + if (globalOpenFileListMirrorActiveFileId === activeFileId) return; + + globalOpenFileListMirrorTabsById + .get(globalOpenFileListMirrorActiveFileId) + ?.classList.remove("active"); + globalOpenFileListMirrorTabsById.get(activeFileId)?.classList.add("active"); + globalOpenFileListMirrorActiveFileId = activeFileId; + } + + function getGlobalOpenFileListMirrorTabSignature(file) { + const tab = file.tab; + const classNames = [...tab.classList] + .filter((className) => className !== "active") + .join(" "); + return `${classNames}\n${tab.innerHTML}`; } function isDraggingFileTab(file) { @@ -4584,7 +4692,9 @@ async function EditorManager($header, $body) { * @returns {number} The number of unsaved files. */ function hasUnsavedFiles() { - const unsavedFiles = manager.files.filter((file) => file.isUnsaved); + const unsavedFiles = manager.files.filter( + (file) => file.refreshUnsavedState?.() ?? file.isUnsaved, + ); return unsavedFiles.length; } diff --git a/src/lib/prettierFormatter.js b/src/lib/prettierFormatter.js index fb1a3b86c..7200e5646 100644 --- a/src/lib/prettierFormatter.js +++ b/src/lib/prettierFormatter.js @@ -1,5 +1,6 @@ import fsOperation from "fileSystem"; import { parse } from "acorn"; +import { getDocText } from "cm/editorUtils"; import toast from "components/toast"; import appSettings from "lib/settings"; import prettierPluginBabel from "prettier/plugins/babel"; @@ -83,7 +84,7 @@ export async function formatActiveFileWithPrettier() { } const doc = editor.state.doc; - const source = doc.toString(); + const source = getDocText(doc); const filepath = file.uri || file.filename || ""; try { const config = await resolvePrettierConfig(file); diff --git a/src/lib/run.js b/src/lib/run.js index d903c9c18..7978c4b6a 100644 --- a/src/lib/run.js +++ b/src/lib/run.js @@ -1,4 +1,5 @@ import fsOperation from "fileSystem"; +import { getDocText } from "cm/editorUtils"; import tutorial from "components/tutorial"; import alert from "dialogs/alert"; import dialog from "dialogs/dialog"; @@ -205,7 +206,7 @@ async function run( break; case EXECUTING_SCRIPT: { - const text = activeFile?.session?.doc?.toString() || ""; + const text = getDocText(activeFile?.session?.doc); sendText(text, reqId, "application/javascript"); break; } @@ -244,7 +245,7 @@ async function run( if (activeFile.SAFMode === "single") { if (filename === reqPath) { sendText( - activeFile.session?.doc?.toString(), + getDocText(activeFile.session?.doc), reqId, mimeType.lookup(filename), ); @@ -283,7 +284,7 @@ async function run( const htmlUrl = Url.join(pathName, reqPath + ".html"); const htmlFile = editorManager.getFile(htmlUrl, "uri"); if (htmlFile?.loaded && htmlFile.isUnsaved) { - sendHTML(htmlFile.session?.doc?.toString(), reqId); + sendHTML(getDocText(htmlFile.session?.doc), reqId); return; } const htmlFs = fsOperation(htmlUrl); @@ -308,7 +309,7 @@ async function run( case ".htm": case ".html": if (!url || (file && file.loaded && file.isUnsaved)) { - sendHTML(file.session?.doc?.toString(), reqId); + sendHTML(getDocText(file.session?.doc), reqId); } else { sendFileContent(url, reqId, MIMETYPE_HTML); } @@ -325,7 +326,7 @@ async function run( .toLowerCase() .replace(/[^a-z0-9]+/g, "-"), }) - .render(file.session?.doc?.toString()); + .render(getDocText(file.session?.doc)); const doc = mustache.render($_markdown, { html, filename, @@ -338,7 +339,7 @@ async function run( default: if (!url || (file && file.loaded && file.isUnsaved)) { sendText( - file.session?.doc?.toString(), + getDocText(file.session?.doc), reqId, mimeType.lookup(file.filename), ); diff --git a/src/lib/saveFile.js b/src/lib/saveFile.js index 44d067fcc..a01286b84 100644 --- a/src/lib/saveFile.js +++ b/src/lib/saveFile.js @@ -1,4 +1,5 @@ import fsOperation from "fileSystem"; +import { getDocText } from "cm/editorUtils"; import prompt from "dialogs/prompt"; import select from "dialogs/select"; import recents from "lib/recents"; @@ -127,7 +128,7 @@ async function saveFile(file, isSaveAs = false) { const savedDoc = file.session?.doc || null; const savedVersion = file.docVersion; - const data = savedDoc ? savedDoc.toString() : ""; + const data = getDocText(savedDoc); await fileOnDevice.writeFile(data, encoding); const stat = await fileOnDevice.stat().catch(() => null); diff --git a/src/pages/markdownPreview/index.js b/src/pages/markdownPreview/index.js index bfcfd62f2..b89ce84a1 100644 --- a/src/pages/markdownPreview/index.js +++ b/src/pages/markdownPreview/index.js @@ -1,6 +1,7 @@ import "./style.scss"; import fsOperation from "fileSystem"; +import { getDocText } from "cm/editorUtils"; import Page from "components/page"; import DOMPurify from "dompurify"; import actionStack from "lib/actionStack"; @@ -480,7 +481,7 @@ function createMarkdownPreview(file) { revokeObjectUrls(previewState.objectUrls); previewState.objectUrls = []; - const markdownText = previewState.file.session?.doc?.toString?.() || ""; + const markdownText = getDocText(previewState.file.session?.doc); const pendingRenderTasks = [ renderMarkdown(markdownText, previewState.file), ]; diff --git a/src/sidebarApps/searchInFiles/index.js b/src/sidebarApps/searchInFiles/index.js index 185ad4049..b6bc032a6 100644 --- a/src/sidebarApps/searchInFiles/index.js +++ b/src/sidebarApps/searchInFiles/index.js @@ -2,6 +2,7 @@ import "./styles.scss"; import fsOperation from "fileSystem"; import { EditorView } from "@codemirror/view"; import autosize from "autosize"; +import { getDocText } from "cm/editorUtils"; import Checkbox from "components/checkbox"; import Sidebar, { preventSlide } from "components/sidebar"; import escapeStringRegexp from "escape-string-regexp"; @@ -621,7 +622,7 @@ async function readSearchFileContent(uri) { const editorFile = editorManager.getFile(uri, "uri"); if (editorFile?.session?.doc) { try { - return editorFile.session.doc.toString() || ""; + return getDocText(editorFile.session.doc); } catch (_) { return ""; } @@ -726,7 +727,7 @@ function getOpenFileOverlays(searchFiles) { if (!file.uri || !supportedUrls.has(file.uri)) return; if (!file.session?.doc) return; try { - overlays[file.uri] = file.session.doc.toString() || ""; + overlays[file.uri] = getDocText(file.session.doc); } catch (_) { // ignore invalid editor docs } From 449b701b281ea2915ed52c2dae558b430b293b1b Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:57:34 +0530 Subject: [PATCH 19/20] cleanup --- src/handlers/editorFileTab.js | 43 +++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index 159b96d03..ef39f4ac7 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -76,6 +76,7 @@ let prevScrollLeft = 0; */ let initialNextSibling = null; let didReorder = false; +let dragSessionId = 0; const MIN_SCROLL_SPEED = 2; const MAX_SCROLL_SPEED = 14; @@ -111,6 +112,7 @@ export default function startDrag(e) { $tabClone = $tab.cloneNode(true); initialNextSibling = $tab.nextElementSibling; didReorder = false; + dragSessionId += 1; const rect = $tab.getBoundingClientRect(); const parentRect = $parent.getBoundingClientRect(); @@ -236,6 +238,7 @@ function releaseDrag(e) { } function finishDrag(shouldSettleClone) { + const dragState = getCurrentDragState(); cancelAnimationFrame(animationFrame); document.removeEventListener("mousemove", onDrag, opts); @@ -248,15 +251,17 @@ function finishDrag(shouldSettleClone) { $parent.removeEventListener("scroll", preventDefaultScroll); if (shouldSettleClone) { - const rect = $tab.getBoundingClientRect(); + const rect = dragState.tab.getBoundingClientRect(); let cleaned = false; + let cleanupTimeout = null; const safeCleanup = () => { if (cleaned) return; cleaned = true; - cleanupDrag(); + if (cleanupTimeout) clearTimeout(cleanupTimeout); + cleanupDrag(dragState); }; animate( - $tabClone, + dragState.tabClone, { transform: `translate3d(${rect.left}px, ${rect.top}px, 0)` }, { duration: document.body.classList.contains("no-animation") @@ -267,17 +272,37 @@ function finishDrag(shouldSettleClone) { ) .then(safeCleanup) .catch(safeCleanup); - setTimeout(safeCleanup, 500); + cleanupTimeout = setTimeout(safeCleanup, 500); return; } - cleanupDrag(); + cleanupDrag(dragState); } -function cleanupDrag() { - $tab.style.opacity = ""; - delete $tab.dataset.editorTabDragging; - $tabClone.remove(); +function getCurrentDragState() { + return { + id: dragSessionId, + tab: $tab, + tabClone: $tabClone, + }; +} + +function cleanupDrag(state = getCurrentDragState()) { + const isCurrentDrag = + state.id === dragSessionId && + state.tabClone && + state.tabClone === $tabClone; + const isSameTabInNewDrag = !isCurrentDrag && state.tab && state.tab === $tab; + + if (state.tab && !isSameTabInNewDrag) { + state.tab.style.opacity = ""; + delete state.tab.dataset.editorTabDragging; + } + + state.tabClone?.remove(); + + if (!isCurrentDrag) return; + editorManager.syncOpenFileList?.(); $tabClone = null; $originParent = null; From 1c478aa6916989349fa94a981e10657027dc2ecf Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:54:55 +0530 Subject: [PATCH 20/20] fix untiled.txt close the pane and remove ctrl-touch things --- src/cm/touchSelectionMenu.js | 112 +---------------------------------- src/lib/editorFile.js | 11 +++- src/lib/editorManager.js | 29 +++++---- 3 files changed, 31 insertions(+), 121 deletions(-) diff --git a/src/cm/touchSelectionMenu.js b/src/cm/touchSelectionMenu.js index 390a81378..44a9e761c 100644 --- a/src/cm/touchSelectionMenu.js +++ b/src/cm/touchSelectionMenu.js @@ -128,12 +128,10 @@ class TouchSelectionMenuController { #container; #getActiveFile; #isShiftSelectionActive; - #isCtrlSelectionActive; #stateSyncRaf = 0; #isScrolling = false; #isPointerInteracting = false; #shiftSelectionSession = null; - #ctrlSelectionSession = null; #pendingShiftSelectionClick = null; #menuActive = false; #menuRequested = false; @@ -149,8 +147,6 @@ class TouchSelectionMenuController { this.#getActiveFile = options.getActiveFile || (() => null); this.#isShiftSelectionActive = options.isShiftSelectionActive || (() => false); - this.#isCtrlSelectionActive = - options.isCtrlSelectionActive || (() => false); this.$menu = document.createElement("menu"); this.$menu.className = "cursor-menu"; this.#bindEvents(); @@ -200,7 +196,6 @@ class TouchSelectionMenuController { cancelAnimationFrame(this.#stateSyncRaf); this.#stateSyncRaf = 0; this.#shiftSelectionSession = null; - this.#ctrlSelectionSession = null; this.#pendingShiftSelectionClick = null; this.#tooltipObserver?.disconnect(); this.#hideMenu(true); @@ -210,7 +205,6 @@ class TouchSelectionMenuController { this.#enabled = !!enabled; if (this.#enabled) return; this.#shiftSelectionSession = null; - this.#ctrlSelectionSession = null; this.#pendingShiftSelectionClick = null; this.#menuRequested = false; this.#isPointerInteracting = false; @@ -280,7 +274,6 @@ class TouchSelectionMenuController { onSessionChanged() { if (!this.#enabled) return; this.#shiftSelectionSession = null; - this.#ctrlSelectionSession = null; this.#pendingShiftSelectionClick = null; this.#menuRequested = false; this.#isPointerInteracting = false; @@ -303,19 +296,15 @@ class TouchSelectionMenuController { if (this.$menu.contains(target)) return; if (this.#isIgnoredPointerTarget(target)) { this.#shiftSelectionSession = null; - this.#ctrlSelectionSession = null; return; } if (target instanceof Node && this.#view.dom.contains(target)) { - if (!this.#captureCtrlSelection(event)) { - this.#captureShiftSelection(event); - } + this.#captureShiftSelection(event); this.#isPointerInteracting = true; this.#clearMenuShowTimer(); return; } this.#shiftSelectionSession = null; - this.#ctrlSelectionSession = null; this.#isPointerInteracting = false; this.#menuRequested = false; this.#hideMenu(); @@ -323,12 +312,9 @@ class TouchSelectionMenuController { #onGlobalPointerUp = (event) => { if (event.type === "pointerup") { - if (!this.#commitCtrlSelection(event)) { - this.#commitShiftSelection(event); - } + this.#commitShiftSelection(event); } else { this.#shiftSelectionSession = null; - this.#ctrlSelectionSession = null; } if (!this.#isPointerInteracting) return; this.#isPointerInteracting = false; @@ -344,7 +330,6 @@ class TouchSelectionMenuController { }; #captureShiftSelection(event) { - this.#ctrlSelectionSession = null; if (!this.#canExtendSelection(event)) { this.#shiftSelectionSession = null; return; @@ -392,92 +377,6 @@ class TouchSelectionMenuController { event.preventDefault(); } - #captureCtrlSelection(event) { - this.#shiftSelectionSession = null; - if (!this.#canAddCursor(event)) { - this.#ctrlSelectionSession = null; - return false; - } - - this.#ctrlSelectionSession = { - pointerId: event.pointerId, - x: event.clientX, - y: event.clientY, - }; - event.preventDefault(); - event.stopPropagation(); - return true; - } - - #commitCtrlSelection(event) { - const session = this.#ctrlSelectionSession; - this.#ctrlSelectionSession = null; - if (!session) return false; - if (!this.#canAddCursor(event)) return false; - if (event.pointerId !== session.pointerId) return false; - if ( - Math.hypot(event.clientX - session.x, event.clientY - session.y) > - TAP_MAX_DISTANCE - ) { - return false; - } - const target = event.target; - if (!(target instanceof Node) || !this.#view.dom.contains(target)) { - return false; - } - if (this.#isIgnoredPointerTarget(target)) return false; - - const pos = this.#view.posAtCoords( - { x: event.clientX, y: event.clientY }, - false, - ); - if (pos == null) return false; - - const selection = this.#view.state.selection; - const ranges = selection.ranges; - const existingIndex = ranges.findIndex( - (range) => range.from <= pos && range.to >= pos, - ); - let nextRanges; - let mainIndex; - - if (existingIndex >= 0) { - if (ranges.length <= 1) { - this.#pendingShiftSelectionClick = { - x: event.clientX, - y: event.clientY, - timeStamp: event.timeStamp, - }; - event.preventDefault(); - event.stopPropagation(); - return true; - } - nextRanges = ranges.filter((_, index) => index !== existingIndex); - mainIndex = Math.min(selection.mainIndex, nextRanges.length - 1); - } else { - const cursor = EditorSelection.cursor(pos); - const insertAt = ranges.findIndex( - (range) => range.from > pos || (range.from === pos && range.to >= pos), - ); - mainIndex = insertAt === -1 ? ranges.length : insertAt; - nextRanges = ranges.slice(); - nextRanges.splice(mainIndex, 0, cursor); - } - - this.#view.dispatch({ - selection: EditorSelection.create(nextRanges, mainIndex), - userEvent: "select.pointer", - }); - this.#pendingShiftSelectionClick = { - x: event.clientX, - y: event.clientY, - timeStamp: event.timeStamp, - }; - event.preventDefault(); - event.stopPropagation(); - return true; - } - #canExtendSelection(event) { if (!this.#enabled) return false; if (!(event.isTrusted && event.isPrimary)) return false; @@ -485,13 +384,6 @@ class TouchSelectionMenuController { return !!this.#isShiftSelectionActive(event); } - #canAddCursor(event) { - if (!this.#enabled) return false; - if (!(event.isTrusted && event.isPrimary)) return false; - if (typeof event.button === "number" && event.button !== 0) return false; - return !!this.#isCtrlSelectionActive(event); - } - consumePendingShiftSelectionClick(event) { const pending = this.#pendingShiftSelectionClick; this.#pendingShiftSelectionClick = null; diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 7be1097f4..170ccd75b 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -1291,11 +1291,19 @@ export default class EditorFile { (file) => file.id !== this.id, ); } - const { files, activeFile } = editorManager; + const { activeFile } = editorManager; const wasActive = activeFile?.id === this.id; if (wasActive) { editorManager.activeFile = null; } + const paneClosed = + !suppressFallback && + this.isPanePlaceholder && + !isUnsaved && + removal?.pane && + !removal.nextFile && + editorManager.closeEmptyPane?.(removal.pane); + const { files } = editorManager; if (!files.length) { Sidebar.hide(); editorManager.activeFile = null; @@ -1310,6 +1318,7 @@ export default class EditorFile { removal?.wasPaneActive && removal.pane && !removal.nextFile && + !paneClosed && !suppressFallback ) { new EditorFile(config.DEFAULT_FILE_NAME, { diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index c9eb37843..ed3737b0a 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -738,9 +738,6 @@ async function EditorManager($header, $body) { if (!appSettings.value.shiftClickSelection) return false; return !!event?.shiftKey || quickTools?.$footer?.dataset?.shift != null; }; - const isCtrlSelectionActive = (event) => { - return !!event?.ctrlKey || quickTools?.$footer?.dataset?.ctrl != null; - }; function registerSoftKeyboardCursorReveal() { const shouldRevealCursor = () => { @@ -1587,7 +1584,6 @@ async function EditorManager($header, $body) { container: $container, getActiveFile: () => manager?.activeFile || null, isShiftSelectionActive, - isCtrlSelectionActive, }); primaryPane.touchSelectionController = touchSelectionController; @@ -2279,7 +2275,6 @@ async function EditorManager($header, $body) { container: pane.editorContainer, getActiveFile: () => pane.activeFile || null, isShiftSelectionActive, - isCtrlSelectionActive, }); await setupEditor(pane); return paneEditor; @@ -2348,10 +2343,10 @@ async function EditorManager($header, $body) { return createPane({ moveFile: file, direction }); } - function closeActivePane() { - const pane = getActivePane(); + function closePane(pane = getActivePane()) { if (!pane || panes.length <= 1) return false; const preferredFile = pane.activeFile; + const wasActivePane = activePane === pane; const orderedPanes = getOrderedPanes(); const paneIndex = orderedPanes.indexOf(pane); @@ -2392,15 +2387,28 @@ async function EditorManager($header, $body) { const fileToActivate = targetPane.files.includes(preferredFile) ? preferredFile : targetPane.activeFile; - if (fileToActivate) { - fileToActivate.makeActive(); + if (wasActivePane || activePane === pane) { + if (fileToActivate) { + fileToActivate.makeActive(); + } else { + activatePane(targetPane, { focusEditor: false }); + } } else { - activatePane(targetPane, { focusEditor: false }); + updateActivePaneLayoutPath(activePane); } syncOpenFileList(); return true; } + function closeActivePane() { + return closePane(getActivePane()); + } + + function closeEmptyPane(pane) { + if (!pane || pane.files.length || panes.length <= 1) return false; + return closePane(pane); + } + function focusPaneByOffset(offset) { if (panes.length <= 1) return false; const orderedPanes = getOrderedPanes(); @@ -2971,6 +2979,7 @@ async function EditorManager($header, $body) { splitPaneRight, splitPaneDown, closeActivePane, + closeEmptyPane, focusNextPane, focusPreviousPane, focusPaneByDirection,