diff --git a/src/app/app.ts b/src/app/app.ts index 4c2b7c3..50b981c 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -7,6 +7,8 @@ import { foldAll, unfoldAll } from '@codemirror/language'; import { gotoLine, findNext, findPrevious } from '@codemirror/search'; import { notepadLightMarker, notepadDarkMarker } from '../editor/notepad-light-theme'; import { DocumentStore } from '../services/document-store'; +import type { ViewId, DocId } from '../services/document-store'; +import type { DockManager } from './dock-manager'; import { PersistenceService } from '../services/persistence-service'; import { SettingsService } from '../services/settings-service'; import type { Settings } from '../services/settings-service'; @@ -76,6 +78,29 @@ import { fnToName, } from '../editor/macro'; +/** + * Two-phase secondary-editor factory result. The host DOM is built first and + * mounted into the dock group; the CM6 EditorView is created only afterwards via + * mount() — creating it before the dock panel is added breaks dockview's group + * insertion (and creating it in an attached, sized element measures correctly). + */ +export interface SecondaryEditorHost { + /** The full secondary host element (tabbar + editor) mounted into the dock. */ + hostEl: HTMLElement; + /** The #tabbar element inside the secondary editor host (for the 2nd TabBar). */ + tabbarEl: HTMLElement; + /** Create the EditorView + EditorController once the host is mounted. */ + mount(): { view: EditorView; controller: EditorController }; +} + +/** A fully-mounted secondary (split) editor pane. */ +interface SecondaryEditor { + view: EditorView; + controller: EditorController; + tabbarEl: HTMLElement; + hostEl: HTMLElement; +} + export interface AppDeps { view: EditorView; controller: EditorController; @@ -92,15 +117,243 @@ export interface AppDeps { symbolCompartment?: Compartment; /** Optional RecentFilesService override (for testing). */ recentFiles?: RecentFilesService; + /** + * Focused-editor refs. All editor commands read `.current`, so repointing + * these on focus change routes every command to the focused split pane. + * If omitted (unit tests), App creates them from view/controller. + */ + viewRef?: { current: EditorView }; + controllerRef?: { current: EditorController }; + /** DockManager for split-view group management. Omitted in unit tests. */ + dockManager?: DockManager; + /** Factory that builds the secondary editor host on first split. Omitted in unit tests. */ + createSecondaryEditor?: () => SecondaryEditorHost; } export class App { - private controller: EditorController; - private view: EditorView; + /** Focused view/controller refs — the single source of truth for "the editor". */ + private viewRef: { current: EditorView }; + private controllerRef: { current: EditorController }; + /** The always-present primary pane. */ + private primaryView: EditorView; + private primaryController: EditorController; + /** The secondary pane + its tab strip, created lazily on first split. */ + private secondary: SecondaryEditor | null = null; + private secondaryTabBar: TabBar | null = null; + /** The primary tab strip, kept so it can be rebuilt/queried. */ + private primaryTabBar: TabBar | null = null; + /** Last-applied settings, replayed onto the secondary pane when it is created. */ + private lastSettings: Settings | null = null; + /** Refresh the status bar cursor from the focused view (set during start()). */ + private refreshStatusCursor: (() => void) | null = null; + /** Lazily-populated refs the tab-bar context menu closes over. */ + private fileActionsRef: { current: FileActions | null } = { current: null }; + private doSaveAsRef: { current: (() => Promise) | null } = { current: null }; + /** Reentrancy guard so collapseSplit()'s store mutations don't re-trigger it. */ + private collapsing = false; + /** True when a persisted split is being restored (drives finishStartup()). */ + private restoredSplit = false; + /** Secondary host built during restore, mounted in finishStartup() once its dock group exists. */ + private pendingSecondaryHost: SecondaryEditorHost | null = null; + + /** The focused editor view (follows the focused split pane). */ + private get view(): EditorView { + return this.viewRef.current; + } + /** The focused editor controller (follows the focused split pane). */ + private get controller(): EditorController { + return this.controllerRef.current; + } constructor(private deps: AppDeps) { - this.controller = deps.controller; - this.view = deps.view; + this.primaryController = deps.controller; + this.primaryView = deps.view; + this.viewRef = deps.viewRef ?? { current: deps.view }; + this.controllerRef = deps.controllerRef ?? { current: deps.controller }; + } + + /** The controller for a specific pane (null if that pane doesn't exist). */ + private controllerFor(view: ViewId): EditorController | null { + return view === 0 ? this.primaryController : (this.secondary?.controller ?? null); + } + private viewFor(view: ViewId): EditorView | null { + return view === 0 ? this.primaryView : (this.secondary?.view ?? null); + } + + /** + * Repoint the focused refs to a pane and sync the store + status bar. + * Idempotent: does NOT drive the dock (call dockManager.focusEditorGroup for that) + * so it is safe to invoke from the dock's own focus-change event. + */ + private applyFocus(view: ViewId): void { + const v = this.viewFor(view); + const c = this.controllerFor(view); + if (!v || !c) return; + this.viewRef.current = v; + this.controllerRef.current = c; + if (this.deps.store.focusedView() !== view) this.deps.store.setFocusedView(view); + this.refreshStatusCursor?.(); + } + + /** Build a tab strip for a given split pane, wired to that pane's controller. */ + private makeTabBar( + root: HTMLElement, + viewId: ViewId, + fileActionsRef: { current: FileActions | null }, + doSaveAsRef: { current: (() => Promise) | null }, + ): TabBar { + const store = this.deps.store; + return new TabBar( + root, + store, + (id) => { + this.applyFocus(viewId); + store.setActiveForView(viewId, id); + this.controllerFor(viewId)?.showDoc(id); + this.deps.dockManager?.focusEditorGroup(viewId); + }, + (id) => { + const doc = store.get(id); + if (doc?.dirty && !confirm(`Discard unsaved changes to ${doc.name}?`)) return; + store.remove(id); + const next = store.activeForView(viewId); + if (next) { + this.controllerFor(viewId)?.showDoc(next.id); + } else if (viewId === 0) { + const d = store.create(); + this.controllerFor(0)?.showDoc(d.id); + } + this.controllerFor(viewId)?.closeDoc(id); + this.maybeCollapseSplit(); + }, + () => { + this.applyFocus(viewId); + const d = store.create(); // create() adds to the focused view (== viewId) + this.controllerFor(viewId)?.showDoc(d.id); + }, + { + onSave: () => void fileActionsRef.current?.saveActive(), + onSaveAs: () => void doSaveAsRef.current?.(), + onCloseAllExceptActive: () => fileActionsRef.current?.closeAllExceptActive(), + onCloseAllToLeft: () => fileActionsRef.current?.closeAllToLeft(), + onCloseAllToRight: () => fileActionsRef.current?.closeAllToRight(), + onReload: () => void fileActionsRef.current?.reloadActive(), + onMoveToOtherView: this.deps.createSecondaryEditor + ? (id) => this.moveToOtherView(id) + : undefined, + }, + viewId, + ); + } + + /** Push settings to a single pane (font size + tab/wrap/autocomplete/theme). */ + private applySettingsToPane(s: Settings, controller: EditorController, view: EditorView): void { + view.dom.style.fontSize = `${s.fontSize}px`; + controller.setEditorOptions({ tabSize: s.tabSize, wordWrap: s.wordWrap }); + const autoCompletionExt = s.autoCompletion + ? autocompletion({ override: [wordCompletionSource] }) + : []; + controller.setAutoCompletion(autoCompletionExt); + const eff = new ThemeService(() => s.theme).effective(); + controller.setTheme(eff === 'dark' ? notepadDarkMarker : notepadLightMarker); + } + + // ── Split view ──────────────────────────────────────────────────────────── + + /** Create the secondary pane + tab strip if not present. Returns false if unsupported. */ + private ensureSecondary(orientation: 'h' | 'v'): boolean { + if (!this.deps.dockManager || !this.deps.createSecondaryEditor) return false; + if (this.secondary) return true; + // Phase 1: build the host DOM and register it, then add the dock group so the + // host is attached. Phase 2: create the EditorView into the mounted host. + const host = this.deps.createSecondaryEditor(); + this.deps.dockManager.setSecondaryEditorHost(host.hostEl); + this.deps.dockManager.addSecondaryEditorGroup(orientation === 'h' ? 'below' : 'right'); + const { view, controller } = host.mount(); + this.secondary = { view, controller, hostEl: host.hostEl, tabbarEl: host.tabbarEl }; + this.secondaryTabBar = this.makeTabBar(host.tabbarEl, 1, this.fileActionsRef, this.doSaveAsRef); + this.secondaryTabBar.render(); + if (this.lastSettings) this.applySettingsToPane(this.lastSettings, controller, view); + return true; + } + + /** View → Split Horizontal / Vertical. Toggles the split off if already split. */ + private doSplit(orientation: 'h' | 'v'): void { + const store = this.deps.store; + if (this.secondary) { + this.collapseSplit(); + return; + } + if (!this.ensureSecondary(orientation)) return; + store.setSplitOrientation(orientation); + const activePrimary = store.activeForView(0); + if (activePrimary) this.moveDocToView(activePrimary.id, 1); + } + + /** Move a document into another pane, refreshing both panes and focus. */ + private moveDocToView(id: DocId, target: ViewId): void { + const store = this.deps.store; + const source: ViewId = target === 1 ? 0 : 1; + const srcController = this.controllerFor(source); + const tgtController = this.controllerFor(target); + if (!tgtController) return; + store.moveToView(id, target); // focus=target, active[target]=id, source active re-pointed + srcController?.closeDoc(id); + const srcActive = store.activeForView(source); + if (srcActive) { + srcController?.showDoc(srcActive.id); + } else if (source === 0) { + // Primary emptied — give it a fresh untitled so it's never blank. + store.setFocusedView(0); + const d = store.create(); + store.setFocusedView(target); + srcController?.showDoc(d.id); + } + tgtController.showDoc(id); + this.deps.dockManager?.focusEditorGroup(target); + this.applyFocus(target); + this.primaryTabBar?.render(); + this.secondaryTabBar?.render(); + } + + /** Tab context-menu "Move to Other View". */ + private moveToOtherView(id: DocId): void { + const store = this.deps.store; + const cur = (store.get(id)?.view ?? 0) as ViewId; + const target: ViewId = cur === 0 ? 1 : 0; + if (target === 1 && !this.secondary && !this.ensureSecondary('v')) return; + this.moveDocToView(id, target); + this.maybeCollapseSplit(); + } + + /** Collapse the split when the secondary pane has no documents left. */ + private maybeCollapseSplit(): void { + if (this.collapsing) return; + if (this.secondary && this.deps.store.listForView(1).length === 0) { + this.collapseSplit(); + } + } + + /** Tear down the secondary pane and return to single view. */ + private collapseSplit(): void { + if (!this.secondary || this.collapsing) return; + this.collapsing = true; + const store = this.deps.store; + // Defensive: move any stragglers back to the primary pane. + for (const d of store.listForView(1)) store.moveToView(d.id, 0); + this.deps.dockManager?.removeSecondaryEditorGroup(); + this.secondaryTabBar?.dispose(); + this.secondaryTabBar = null; + this.secondary.controller.dispose(); + this.secondary = null; + this.restoredSplit = false; + store.setSplitOrientation(null); + store.setFocusedView(0); + this.applyFocus(0); + const a = store.activeForView(0); + if (a) this.primaryController.showDoc(a.id); + this.primaryTabBar?.render(); + this.collapsing = false; } /** @@ -131,51 +384,39 @@ export class App { const restored = await this.deps.persistence.loadSession().catch(() => null); if (restored && restored.docs.length) { restored.docs.forEach((d) => this.deps.store.add(d)); - if (restored.activeId) this.deps.store.setActive(restored.activeId); + // Per-view active restore, falling back to the legacy single activeId. + if (restored.activeIds) { + const [a0, a1] = restored.activeIds; + if (a0) this.deps.store.setActiveForView(0, a0); + if (a1) this.deps.store.setActiveForView(1, a1); + } else if (restored.activeId) { + this.deps.store.setActive(restored.activeId); + } + this.deps.store.setFocusedView(0); + if (restored.splitOrientation) this.deps.store.setSplitOrientation(restored.splitOrientation); } else { this.deps.store.create(); } // fileActionsRef and doSaveAsRef are populated after their respective values are - // created below. The tabBarContextCallbacks wrapper closes over them lazily. - const fileActionsRef: { current: FileActions | null } = { current: null }; - const doSaveAsRef: { current: (() => Promise) | null } = { current: null }; + // created below. The tab-bar context callbacks close over them lazily. + const fileActionsRef = this.fileActionsRef; + const doSaveAsRef = this.doSaveAsRef; - const tabbar = new TabBar( + const tabbar = this.makeTabBar( document.getElementById('tabbar')!, - this.deps.store, - (id) => { - this.deps.store.setActive(id); - this.controller.showDoc(id); - }, - (id) => { - const doc = this.deps.store.get(id); - if (doc?.dirty && !confirm(`Discard unsaved changes to ${doc.name}?`)) return; - this.deps.store.remove(id); - const next = this.deps.store.active(); - if (next) { - this.controller.showDoc(next.id); - } else { - const d = this.deps.store.create(); - this.controller.showDoc(d.id); - } - this.controller.closeDoc(id); - }, - () => { - const d = this.deps.store.create(); - this.controller.showDoc(d.id); - }, - { - onSave: () => void fileActionsRef.current?.saveActive(), - onSaveAs: () => void doSaveAsRef.current?.(), - onCloseAllExceptActive: () => fileActionsRef.current?.closeAllExceptActive(), - onCloseAllToLeft: () => fileActionsRef.current?.closeAllToLeft(), - onCloseAllToRight: () => fileActionsRef.current?.closeAllToRight(), - onReload: () => void fileActionsRef.current?.reloadActive(), - }, + 0, + fileActionsRef, + doSaveAsRef, ); + this.primaryTabBar = tabbar; tabbar.render(); + // Route focus changes from the dock (clicking into a pane) to our refs, and + // collapse the split when the secondary pane empties. + this.deps.dockManager?.onEditorFocusChange((v) => this.applyFocus(v)); + this.deps.store.subscribe(() => this.maybeCollapseSplit()); + const sync = new SessionSync(this.deps.store, this.deps.persistence); sync.attach(); document.addEventListener('visibilitychange', () => { @@ -198,7 +439,8 @@ export class App { const fileActions = new FileActions({ file: this.deps.file, store: this.deps.store, - controller: this.controller, + controller: this.primaryController, + controllerRef: this.controllerRef, }); fileActionsRef.current = fileActions; @@ -229,47 +471,36 @@ export class App { // StatusBar: shows language · EOL · cursor for the active doc. const statusbar = new StatusBar(document.getElementById('statusbar')!, this.deps.store); - // Update cursor position from CM6 view update — wired via the controller's - // onUpdate which is called from the updateListener extension in editor-page.ts. - this.view.dom.addEventListener('cm-cursor-change', (e: Event) => { - const ce = e as CustomEvent<{ line: number; col: number }>; + // Update cursor position from CM6 view updates. The cm-cursor-change event + // bubbles to document from EITHER split pane and carries a `view` id, so we + // listen once on document and reflect only the currently focused pane. + document.addEventListener('cm-cursor-change', (e: Event) => { + const ce = e as CustomEvent<{ line: number; col: number; view?: ViewId }>; + if (ce.detail.view !== undefined && ce.detail.view !== this.deps.store.focusedView()) return; statusbar.setCursor(ce.detail.line, ce.detail.col); }); + // On focus switch, re-read the newly focused pane's cursor into the status bar. + this.refreshStatusCursor = () => { + const v = this.view; + const pos = v.state.selection.main.head; + const line = v.state.doc.lineAt(pos); + statusbar.setCursor(line.number, pos - line.from + 1); + }; // Track the current word-wrap state so the toolbar can read it synchronously. // Initialised to false; updated by applySettings() and doWordWrap(). let wordWrapActive = false; // applySettings: push fontSize/tabSize/wordWrap/theme/autoCompletion to CM6. + // Applied to BOTH split panes so they stay visually consistent; the current + // settings are also stored so a pane created later (split) inherits them. const applySettings = (s: Settings): void => { wordWrapActive = s.wordWrap; - // ── Font size ────────────────────────────────────────────────────────── - // Written to the editor DOM element's inline style; __getFontSize() reads it. - this.view.dom.style.fontSize = `${s.fontSize}px`; - - // ── Tab size + Word wrap ─────────────────────────────────────────────── - // Delegate to EditorController.setEditorOptions() which BOTH reconfigures - // the active view's compartments AND stores the current Extension values so - // that new document states created by showDoc() (new tabs opened later) - // are seeded with the current settings instead of CM6 defaults. - this.controller.setEditorOptions({ tabSize: s.tabSize, wordWrap: s.wordWrap }); - - // ── Autocompletion (AutoCompletion decorator faithful mapping) ───────── - // Gated by Settings.autoCompletion. Uses wordCompletionSource (document-word - // source faithful to NotepadNext AutoCompletion). The compartment lives on the - // controller so new tabs opened after a settings change inherit the setting. - const autoCompletionExt = s.autoCompletion - ? autocompletion({ override: [wordCompletionSource] }) - : []; - this.controller.setAutoCompletion(autoCompletionExt); - - // ── Theme ────────────────────────────────────────────────────────────── - const eff = new ThemeService(() => s.theme).effective(); - // Swap the theme compartment MARKER: dark vs light. Both modes are fully - // styled by notepadBase's &light/&dark rules (in sharedExtensions); the - // marker just selects which scope fires. setTheme() also stores the marker - // in _currentThemeExt so tabs opened after a theme change inherit it. - this.controller.setTheme(eff === 'dark' ? notepadDarkMarker : notepadLightMarker); + this.lastSettings = s; + this.applySettingsToPane(s, this.primaryController, this.primaryView); + if (this.secondary) { + this.applySettingsToPane(s, this.secondary.controller, this.secondary.view); + } }; // SettingsPanel: opened via Ctrl/Cmd+Comma (handled in keydown above). @@ -854,6 +1085,8 @@ export class App { viewEditorInspector: doEditorInspector, viewLanguageInspector: doLanguageInspector, viewLuaConsole: doLuaConsole, + viewSplitHorizontal: () => this.doSplit('h'), + viewSplitVertical: () => this.doSplit('v'), langItems: buildLangItems(), macroStartRecording: () => { startRecording(); @@ -976,6 +1209,8 @@ export class App { viewEditorInspector: doEditorInspector, viewLanguageInspector: doLanguageInspector, viewLuaConsole: doLuaConsole, + viewSplitHorizontal: () => this.doSplit('h'), + viewSplitVertical: () => this.doSplit('v'), fileOpenFolder: doOpenFolder, langItems: [], searchToggleBookmark: () => runCmd(cmdToggleBookmark), @@ -1266,5 +1501,53 @@ export class App { // Apply persisted settings to editor on startup. applySettings(loaded); + + // If a split was persisted, build the secondary pane now (its dock group is + // added after dockview init, in finishStartup()). lastSettings is set above. + if (this.deps.store.hasView(1) && this.deps.createSecondaryEditor && this.deps.dockManager) { + this.prepareSecondaryForRestore(); + } + } + + /** + * Build the secondary pane's view/tab strip for session restore, without adding + * its dock group yet (the group is created — or resolved from the persisted dock + * layout — after dockview init, in finishStartup()). + */ + private prepareSecondaryForRestore(): void { + if (this.secondary || this.pendingSecondaryHost) return; + if (!this.deps.createSecondaryEditor || !this.deps.dockManager) return; + // Build + register the host DOM only. The dock group (from the persisted + // layout, or added in finishStartup) attaches it; the view mounts afterwards. + const host = this.deps.createSecondaryEditor(); + this.pendingSecondaryHost = host; + this.deps.dockManager.setSecondaryEditorHost(host.hostEl); + this.restoredSplit = true; + } + + /** + * Called by the bootstrap after dockview init. Adds the secondary dock group if + * a restored split's dock layout didn't already recreate it, then mounts the + * secondary EditorView into the (now attached) host and shows its active doc. + */ + finishStartup(): void { + const host = this.pendingSecondaryHost; + if (!host || !this.deps.dockManager) { + this.secondary?.view.requestMeasure(); + return; + } + this.pendingSecondaryHost = null; + if (!this.deps.dockManager.isSplit()) { + const o = this.deps.store.splitOrientation ?? 'v'; + this.deps.dockManager.addSecondaryEditorGroup(o === 'h' ? 'below' : 'right'); + } + const { view, controller } = host.mount(); + this.secondary = { view, controller, hostEl: host.hostEl, tabbarEl: host.tabbarEl }; + this.secondaryTabBar = this.makeTabBar(host.tabbarEl, 1, this.fileActionsRef, this.doSaveAsRef); + this.secondaryTabBar.render(); + if (this.lastSettings) this.applySettingsToPane(this.lastSettings, controller, view); + const a = this.deps.store.activeForView(1); + if (a) controller.showDoc(a.id); + view.requestMeasure(); } } diff --git a/src/app/dock-manager.test.ts b/src/app/dock-manager.test.ts index db07747..94d5324 100644 --- a/src/app/dock-manager.test.ts +++ b/src/app/dock-manager.test.ts @@ -17,6 +17,7 @@ vi.mock('dockview-core', () => { _panels: Map = new Map(); _layoutChangeListeners: Array<() => void> = []; _removePanelListeners: Array<(panel: { id: string }) => void> = []; + _activeGroupListeners: Array<(group: unknown) => void> = []; constructor() { /* no-op — container / opts ignored in tests */ @@ -26,9 +27,22 @@ vi.mock('dockview-core', () => { this._panels.set(opts.id, opts); } + // Stable group object per panel id so identity comparisons (=== activeGroup) + // behave like the real dockview. + _groups: Map = new Map(); + getGroupPanel(id: string) { if (!this._panels.has(id)) return undefined; const panels = this._panels; + if (!this._groups.has(id)) { + this._groups.set(id, { + element: document.createElement('div'), + locked: false, + setActive() { + /* no-op in tests */ + }, + }); + } return { id, api: { @@ -36,10 +50,7 @@ vi.mock('dockview-core', () => { panels.delete(id); }, }, - group: { - element: document.createElement('div'), - locked: false, - }, + group: this._groups.get(id), }; } @@ -76,6 +87,15 @@ vi.mock('dockview-core', () => { }; } + onDidActiveGroupChange(fn: (group: unknown) => void) { + this._activeGroupListeners.push(fn); + return { + dispose() { + /* no-op */ + }, + }; + } + /** Test helper: simulate dockview removing a panel natively. */ simulateNativeRemove(id: string): void { this._panels.delete(id); @@ -84,6 +104,13 @@ vi.mock('dockview-core', () => { } } + /** Test helper: simulate the focused group changing. */ + simulateActiveGroupChange(group: unknown): void { + for (const fn of this._activeGroupListeners) { + fn(group); + } + } + layout(): void { /* no-op */ } @@ -261,3 +288,58 @@ describe('DockManager', () => { expect(manager.isPanelVisible('p-reopen')).toBe(true); }); }); + +describe('DockManager — split view', () => { + let manager: DockManager; + + beforeEach(() => { + manager = new DockManager(); + }); + + it('is not split by default', async () => { + await manager.init(makeEl(), makeEl('editor'), makeEl('tabbar')); + expect(manager.isSplit()).toBe(false); + }); + + it('addSecondaryEditorGroup adds the editor2 group once a host is set', async () => { + await manager.init(makeEl(), makeEl('editor'), makeEl('tabbar')); + manager.setSecondaryEditorHost(makeEl('dock-editor-host-2')); + manager.addSecondaryEditorGroup('right'); + expect(manager.isSplit()).toBe(true); + }); + + it('addSecondaryEditorGroup is a no-op without a host', async () => { + await manager.init(makeEl(), makeEl('editor'), makeEl('tabbar')); + manager.addSecondaryEditorGroup('right'); + expect(manager.isSplit()).toBe(false); + }); + + it('removeSecondaryEditorGroup collapses the split', async () => { + await manager.init(makeEl(), makeEl('editor'), makeEl('tabbar')); + manager.setSecondaryEditorHost(makeEl('dock-editor-host-2')); + manager.addSecondaryEditorGroup('below'); + expect(manager.isSplit()).toBe(true); + + manager.removeSecondaryEditorGroup(); + expect(manager.isSplit()).toBe(false); + }); + + it('onEditorFocusChange fires 1 for the secondary group and 0 otherwise', async () => { + await manager.init(makeEl(), makeEl('editor'), makeEl('tabbar')); + manager.setSecondaryEditorHost(makeEl('dock-editor-host-2')); + manager.addSecondaryEditorGroup('right'); + + const seen: Array<0 | 1> = []; + manager.onEditorFocusChange((v) => seen.push(v)); + + const comp = manager.component as unknown as { + getGroupPanel(id: string): { group: unknown } | undefined; + simulateActiveGroupChange(group: unknown): void; + }; + const secondaryGroup = comp.getGroupPanel('editor2')!.group; + const primaryGroup = comp.getGroupPanel('editor')!.group; + comp.simulateActiveGroupChange(secondaryGroup); + comp.simulateActiveGroupChange(primaryGroup); + expect(seen).toEqual([1, 0]); + }); +}); diff --git a/src/app/dock-manager.ts b/src/app/dock-manager.ts index b05f9ab..ecae0e0 100644 --- a/src/app/dock-manager.ts +++ b/src/app/dock-manager.ts @@ -113,6 +113,12 @@ export class DockManager { private _visiblePanels = new Set(); /** Stored resize handler so it can be removed on re-init. */ private _resizeHandler: (() => void) | null = null; + /** Host element for the secondary (split) editor group, set lazily. */ + private _secondaryHost: HTMLElement | null = null; + /** Stable renderer for the secondary editor group (reused across from/toJSON). */ + private _secondaryRenderer: EditorRenderer | null = null; + /** Callback fired when the focused editor group changes (0 = primary, 1 = secondary). */ + private _onEditorFocusChange: ((view: 0 | 1) => void) | null = null; /** * Initialise the dock layout inside `container`. @@ -162,6 +168,11 @@ export class DockManager { if (options.name === 'editor') { return editorRenderer; } + // Secondary (split) editor group — resolved to the host set via + // setSecondaryEditorHost(). Present when restoring a persisted split. + if (options.name === 'editor2' && this._secondaryRenderer) { + return this._secondaryRenderer; + } // Look up the registered panel definition. const def = this._panelDefs.get(options.name); if (def) { @@ -216,6 +227,19 @@ export class DockManager { // side/bottom panel headers intact. this._markEditorGroup(); + // If a persisted layout restored a secondary editor group, mark+lock it too. + if (this._component.getGroupPanel('editor2')) { + this._markSecondaryGroup(); + } + + // Track which editor group is focused so the app can route commands to it. + // The event delivers the newly-active group directly. + this._component.onDidActiveGroupChange((activeGroup) => { + if (!this._onEditorFocusChange) return; + const secondary = this._component?.getGroupPanel('editor2')?.group; + this._onEditorFocusChange(secondary && activeGroup === secondary ? 1 : 0); + }); + // Keep _visiblePanels in sync when the user closes a panel via dockview's // own close button (native close bypasses togglePanel). this._component.onDidRemovePanel((panel: IDockviewPanel) => { @@ -272,6 +296,61 @@ export class DockManager { return this._component; } + // ── Split view (secondary editor group) ────────────────────────────────────── + + /** + * Provide the DOM host (tabbar + editor wrapper) for the secondary editor group. + * Must be called before addSecondaryEditorGroup() or before init() when a + * persisted split is being restored, so createComponent can resolve 'editor2'. + */ + setSecondaryEditorHost(hostEl: HTMLElement): void { + this._secondaryHost = hostEl; + this._secondaryRenderer = new EditorRenderer(hostEl); + } + + /** True if the secondary editor group is currently present. */ + isSplit(): boolean { + return !!this._component?.getGroupPanel('editor2'); + } + + /** + * Create the secondary editor group beside the primary. + * direction 'right' = vertical split (side-by-side), 'below' = horizontal (stacked). + * No-op if already split or if the secondary host hasn't been set. + */ + addSecondaryEditorGroup(direction: 'right' | 'below'): void { + if (!this._component || !this._secondaryHost || this.isSplit()) return; + const editorPanel: IDockviewPanel | undefined = this._component.getGroupPanel('editor'); + const refGroup = editorPanel?.group; + this._component.addPanel({ + id: 'editor2', + component: 'editor2', + title: 'Editor 2', + position: refGroup ? { referenceGroup: refGroup, direction } : { direction }, + }); + this._markSecondaryGroup(); + } + + /** Remove the secondary editor group (collapse the split). No-op if not split. */ + removeSecondaryEditorGroup(): void { + if (!this._component) return; + const panel = this._component.getGroupPanel('editor2'); + if (panel) panel.api.close(); + } + + /** Register a callback invoked when the focused editor group changes (0/1). */ + onEditorFocusChange(cb: (view: 0 | 1) => void): void { + this._onEditorFocusChange = cb; + } + + /** Programmatically focus an editor group (0 = primary, 1 = secondary). */ + focusEditorGroup(view: 0 | 1): void { + if (!this._component) return; + const id = view === 1 ? 'editor2' : 'editor'; + const group = this._component.getGroupPanel(id)?.group; + if (group) group.setActive(true); + } + // ── Private ───────────────────────────────────────────────────────────────── private _showPanel(id: string): void { @@ -342,6 +421,20 @@ export class DockManager { group.locked = true; } + /** + * Mark+lock the secondary editor group the same way as the primary so its + * native dockview tab strip is hidden (our custom #tabbar is used instead) and + * it can't be dragged out or closed via native controls. + */ + private _markSecondaryGroup(): void { + if (!this._component) return; + const panel: IDockviewPanel | undefined = this._component.getGroupPanel('editor2'); + const group = panel?.group; + if (!group) return; + group.element.classList.add('dock-editor-group'); + group.locked = true; + } + private async _persist(): Promise { if (!this._component || !this._storage) return; try { diff --git a/src/app/file-actions.ts b/src/app/file-actions.ts index e502bcb..5c4292f 100644 --- a/src/app/file-actions.ts +++ b/src/app/file-actions.ts @@ -8,21 +8,30 @@ import type { Doc } from '../services/document-store'; export class FileActions { private file: FileService; private store: DocumentStore; - private controller: EditorController; + /** Resolves the currently focused controller (follows split-view focus). */ + private getController: () => EditorController; private confirmFn: (m: string) => boolean; constructor(deps: { file: FileService; store: DocumentStore; controller: EditorController; + /** Optional focused-controller ref; when given, file ops target the focused pane. */ + controllerRef?: { current: EditorController }; confirmFn?: (m: string) => boolean; }) { this.file = deps.file; this.store = deps.store; - this.controller = deps.controller; + const ref = deps.controllerRef; + this.getController = ref ? () => ref.current : () => deps.controller; this.confirmFn = deps.confirmFn ?? ((m) => confirm(m)); } + /** The controller for the currently focused editor pane. */ + private get controller(): EditorController { + return this.getController(); + } + async openFile(): Promise { const res = await this.file.open(); if (!res) return; diff --git a/src/app/menu-bar.test.ts b/src/app/menu-bar.test.ts index b2a668a..4442b3d 100644 --- a/src/app/menu-bar.test.ts +++ b/src/app/menu-bar.test.ts @@ -85,6 +85,8 @@ function makeActions(overrides: Partial = {}): MenuBarActions { viewEditorInspector: noop, viewLanguageInspector: noop, viewLuaConsole: noop, + viewSplitHorizontal: noop, + viewSplitVertical: noop, fileOpenFolder: noop, langItems: [], searchToggleBookmark: noop, diff --git a/src/app/menu-bar.ts b/src/app/menu-bar.ts index a0e6a6c..5bf6895 100644 --- a/src/app/menu-bar.ts +++ b/src/app/menu-bar.ts @@ -479,6 +479,8 @@ export class MenuBar { viewEditorInspector, viewLanguageInspector, viewLuaConsole, + viewSplitHorizontal, + viewSplitVertical, langItems, searchToggleBookmark, searchNextBookmark, @@ -778,8 +780,8 @@ export class MenuBar { disabled('Unfold Level 9'), ]), sep(), - disabled('Split Horizontal'), - disabled('Split Vertical'), + enabled('Split Horizontal', viewSplitHorizontal), + enabled('Split Vertical', viewSplitVertical), ], }; @@ -1023,6 +1025,10 @@ export interface MenuBarActions { viewEditorInspector: () => void; viewLanguageInspector: () => void; viewLuaConsole: () => void; + /** View → Split Horizontal (secondary pane stacked below). */ + viewSplitHorizontal: () => void; + /** View → Split Vertical (secondary pane side-by-side). */ + viewSplitVertical: () => void; langItems: LangItem[]; // Bookmarks (Search → Bookmarks submenu) searchToggleBookmark: () => void; diff --git a/src/app/session-sync.ts b/src/app/session-sync.ts index 950147e..14f0c59 100644 --- a/src/app/session-sync.ts +++ b/src/app/session-sync.ts @@ -36,6 +36,8 @@ export class SessionSync { await this.persistence.saveSession({ docs: this.store.list(), activeId: this.store.activeId, + activeIds: [this.store.activeForView(0)?.id ?? null, this.store.activeForView(1)?.id ?? null], + splitOrientation: this.store.splitOrientation, }); } } diff --git a/src/app/tabbar.ts b/src/app/tabbar.ts index b82a94d..e2828a7 100644 --- a/src/app/tabbar.ts +++ b/src/app/tabbar.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -import type { DocumentStore, DocId } from '../services/document-store'; +import type { DocumentStore, DocId, ViewId } from '../services/document-store'; import { showContextMenu } from './context-menu'; export interface TabBarContextCallbacks { @@ -9,6 +9,8 @@ export interface TabBarContextCallbacks { onCloseAllToLeft: () => void; onCloseAllToRight: () => void; onReload: () => void; + /** Move the tab to the other split view (present only when split is available). */ + onMoveToOtherView?: (id: DocId) => void; } export class TabBar { @@ -27,6 +29,8 @@ export class TabBar { private onClose: (id: DocId) => void, private onNew: () => void, private contextCallbacks?: TabBarContextCallbacks, + /** Which split pane this tab strip represents (0 = primary, 1 = secondary). */ + private viewId: ViewId = 0, ) { this._onResize = () => this.updateOverflow(); this._onDocClick = (e: MouseEvent) => { @@ -53,10 +57,11 @@ export class TabBar { this.closeDropdown(); this.root.innerHTML = ''; - for (const doc of this.store.list()) { + const activeIdForView = this.store.activeForView(this.viewId)?.id; + for (const doc of this.store.listForView(this.viewId)) { const tab = document.createElement('div'); tab.className = - 'tab' + (doc.id === this.store.activeId ? ' active' : '') + (doc.dirty ? ' dirty' : ''); + 'tab' + (doc.id === activeIdForView ? ' active' : '') + (doc.dirty ? ' dirty' : ''); tab.dataset.id = doc.id; // Disk-state indicator (faithful to NotepadNext tab disk icon): blue = in // sync with disk, red = unsaved edits, red+ring = changed externally. @@ -126,6 +131,12 @@ export class TabBar { action: cb ? () => cb.onReload() : undefined, }, { label: '', type: 'separator', enabled: false }, + { + label: 'Move to Other View', + enabled: cb?.onMoveToOtherView !== undefined, + action: cb?.onMoveToOtherView ? () => cb.onMoveToOtherView!(doc.id) : undefined, + }, + { label: '', type: 'separator', enabled: false }, { label: 'Copy Full Path', // Browsers deliberately hide absolute paths from File System Access @@ -163,16 +174,18 @@ export class TabBar { this.root.appendChild(tab); } - // New-tab button + // New-tab button. View 0 keeps the bare id for e2e/test back-compat; the + // secondary strip suffixes it so the two bars don't share a DOM id. const add = document.createElement('button'); - add.id = 'tab-new'; + add.id = this.viewId === 0 ? 'tab-new' : 'tab-new-1'; + add.className = 'tab-new-btn'; add.textContent = '+'; add.addEventListener('click', () => this.onNew()); this.root.appendChild(add); // Overflow chevron button (hidden by default; shown in updateOverflow) const chevron = document.createElement('button'); - chevron.id = 'tab-overflow'; + chevron.id = this.viewId === 0 ? 'tab-overflow' : 'tab-overflow-1'; chevron.className = 'tab-overflow-btn'; chevron.textContent = '»'; chevron.setAttribute('aria-haspopup', 'menu'); @@ -211,7 +224,7 @@ export class TabBar { } // Available width = strip width minus the new-tab button and overflow button. - const addBtn = this.root.querySelector('#tab-new'); + const addBtn = this.root.querySelector('.tab-new-btn'); const addWidth = addBtn ? addBtn.offsetWidth : 0; // Use the overflow button's own width for reservation, or a default of 28px // (it will be hidden initially so offsetWidth may be 0). @@ -247,12 +260,12 @@ export class TabBar { menu.className = 'tab-overflow-menu'; menu.setAttribute('role', 'menu'); - const docs = this.store.list(); + const activeIdForView = this.store.activeForView(this.viewId)?.id; for (const id of overflowIds) { - const doc = docs.find((d) => d.id === id); + const doc = this.store.get(id); if (!doc) continue; const li = document.createElement('li'); - li.className = 'tab-overflow-item' + (doc.id === this.store.activeId ? ' active' : ''); + li.className = 'tab-overflow-item' + (doc.id === activeIdForView ? ' active' : ''); li.setAttribute('role', 'menuitem'); li.setAttribute('tabindex', '0'); li.textContent = (doc.dirty ? '● ' : '') + doc.name; diff --git a/src/app/toolbar.test.ts b/src/app/toolbar.test.ts index d1090d0..75c83b3 100644 --- a/src/app/toolbar.test.ts +++ b/src/app/toolbar.test.ts @@ -86,6 +86,8 @@ function makeActions(overrides: Partial = {}): MenuBarActions { viewEditorInspector: noop, viewLanguageInspector: noop, viewLuaConsole: noop, + viewSplitHorizontal: noop, + viewSplitVertical: noop, fileOpenFolder: noop, langItems: [], searchToggleBookmark: noop, diff --git a/src/editor-page.ts b/src/editor-page.ts index 6879235..3ef1957 100644 --- a/src/editor-page.ts +++ b/src/editor-page.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later import { EditorState, EditorSelection, Compartment, Prec } from '@codemirror/state'; +import type { Extension } from '@codemirror/state'; import { EditorView, ViewUpdate, lineNumbers, highlightActiveLine, keymap } from '@codemirror/view'; import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; import { bracketMatching, indentOnInput } from '@codemirror/language'; @@ -8,6 +9,8 @@ import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirro import { LuaFactory } from 'wasmoon'; import './styles.css'; import { DocumentStore } from './services/document-store'; +import type { ViewId } from './services/document-store'; +import type { SecondaryEditorHost } from './app/app'; import { PersistenceService } from './services/persistence-service'; import { SettingsService } from './services/settings-service'; import { ThemeService } from './services/theme-service'; @@ -188,7 +191,9 @@ const editKeymapCompartment = new Compartment(); // Mutable reference so the updateListener closure (created before the // EditorController is instantiated) can reach the controller once set. // The object wrapper avoids the prefer-const lint error on a reassigned let. +// controllerRef → primary pane controller; controllerBRef → secondary (split) pane. const controllerRef: { current: EditorController | null } = { current: null }; +const controllerBRef: { current: EditorController | null } = { current: null }; // ── Shared extensions ──────────────────────────────────────────────────────── // @@ -200,80 +205,92 @@ const controllerRef: { current: EditorController | null } = { current: null }; // We define them once here and pass them to the controller via // setSharedExtensions() after construction. The initial view state also uses // them (keeping the two in sync). -const sharedExtensions = [ - lineNumbers(), - highlightActiveLine(), - history(), - // Bookmarks: StateField + gutter marker (faithful to NotepadNext BookMarkDecorator). - // Must be in sharedExtensions so every per-doc EditorState carries the bookmark - // StateField, and view.setState() preserves per-doc bookmarks across tab switches. - bookmarkExtension, - // Markers: 3 color layers for "Mark All Occurrences" (faithful to NotepadNext MarkerAppDecorator). - // Must be in sharedExtensions so every per-doc EditorState carries the markState. - markerExtension, - // Find-highlight: dedicated yellow highlight for Find dialog's Mark All (P6.3). - // Faithful to NotepadNext "find_mark_highlight" INDIC_FULLBOX indicator (#FFCC00). - // SEPARATE from the P4.3 marker slots — orthogonal system. - findHighlightExtension, - // URL links: underlines clickable URLs; Ctrl/Cmd+click opens in new tab. - ...urlLinksExtension, - // HTML tag auto-close: inserts `` when user types `>` in HTML docs. - // Faithful to NotepadNext HTMLAutoCompleteDecorator. - htmlAutoCloseExtension, - // Macro recorder: captures key presses and text input during recording. - macroRecorderExtension, - // BraceMatch → faithful NotepadNext bracket-pair highlight (()[]{}) - bracketMatching(), - indentOnInput(), - // SmartHighlighter → highlight all occurrences of the word at caret - highlightSelectionMatches(), - // SurroundSelection + auto-close brackets → closeBrackets() wraps a - // non-empty selection when you type a bracket/quote (faithful NotepadNext). - closeBrackets(), - // Keymap ordering (highest-priority first via Prec.high): - // 1. closeBracketsKeymap — Backspace must delete a bracket pair before - // the default Backspace fires (must be highest priority). - // 2. completionKeymap — Enter/Tab/Escape for autocomplete dropdown. - // 3. searchKeymap — Ctrl+F / Ctrl+H / F3 etc. - // 4. indentWithTab + defaultKeymap + historyKeymap — CM6 defaults last. - // indentWithTab makes Tab indent / Shift-Tab dedent (faithful to - // NotepadNext); it sits BELOW completionKeymap's Prec.high Tab, so when - // the autocomplete popup is open Tab accepts the completion, and when it - // is closed Tab indents instead of moving focus out of the editor. - // 5. editKeymapCompartment — injected by App.ts (Ctrl+/, Alt+Down …). - // Registered at normal precedence so it doesn't override the above. - // search() initializes the searchState StateField so setSearchQuery / findNext / - // findPrevious / replaceAll work without requiring the CM6 search panel to be - // opened first (P6.2 Find dialog uses these commands directly). - search(), - Prec.high(keymap.of([...closeBracketsKeymap, ...completionKeymap])), - keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]), - editKeymapCompartment.of([]), - // Notepad++ light theme base rules (bg/caret/gutter/selection). - // Included here so the initial EditorView state (before controller.setSharedExtensions - // creates the first per-doc state) renders with the correct light theme immediately. - // Per-doc states seed the controller-owned themeCompartment with the active - // theme MARKER (default: notepadLightMarker) so new tabs inherit the theme. - // Only the base CSS (both &light and &dark scopes) lives here; the marker in - // the compartment decides which scope fires — avoiding a light/dark conflict. - notepadBase, - notepadHighlight, - EditorView.updateListener.of((update: ViewUpdate) => { - // Route to the controller which writes to the DocumentStore. - controllerRef.current?.onUpdate(update); - // Emit a custom event for cursor tracking in StatusBar. - if (update.selectionSet) { - const pos = update.state.selection.main.head; - const line = update.state.doc.lineAt(pos); - view.dom.dispatchEvent( - new CustomEvent('cm-cursor-change', { - bubbles: true, - detail: { line: line.number, col: pos - line.from + 1 }, - }), - ); - } - }), -]; +// +// buildSharedExtensions() is parameterised by the pane's controller ref and view +// id so the SAME extension set can back a second (split) EditorView, each routing +// its updates to its own controller and tagging cursor events with its view id. +function buildSharedExtensions( + ctrlRef: { current: EditorController | null }, + viewId: ViewId, +): Extension[] { + return [ + lineNumbers(), + highlightActiveLine(), + history(), + // Bookmarks: StateField + gutter marker (faithful to NotepadNext BookMarkDecorator). + // Must be in sharedExtensions so every per-doc EditorState carries the bookmark + // StateField, and view.setState() preserves per-doc bookmarks across tab switches. + bookmarkExtension, + // Markers: 3 color layers for "Mark All Occurrences" (faithful to NotepadNext MarkerAppDecorator). + // Must be in sharedExtensions so every per-doc EditorState carries the markState. + markerExtension, + // Find-highlight: dedicated yellow highlight for Find dialog's Mark All (P6.3). + // Faithful to NotepadNext "find_mark_highlight" INDIC_FULLBOX indicator (#FFCC00). + // SEPARATE from the P4.3 marker slots — orthogonal system. + findHighlightExtension, + // URL links: underlines clickable URLs; Ctrl/Cmd+click opens in new tab. + ...urlLinksExtension, + // HTML tag auto-close: inserts `` when user types `>` in HTML docs. + // Faithful to NotepadNext HTMLAutoCompleteDecorator. + htmlAutoCloseExtension, + // Macro recorder: captures key presses and text input during recording. + macroRecorderExtension, + // BraceMatch → faithful NotepadNext bracket-pair highlight (()[]{}) + bracketMatching(), + indentOnInput(), + // SmartHighlighter → highlight all occurrences of the word at caret + highlightSelectionMatches(), + // SurroundSelection + auto-close brackets → closeBrackets() wraps a + // non-empty selection when you type a bracket/quote (faithful NotepadNext). + closeBrackets(), + // Keymap ordering (highest-priority first via Prec.high): + // 1. closeBracketsKeymap — Backspace must delete a bracket pair before + // the default Backspace fires (must be highest priority). + // 2. completionKeymap — Enter/Tab/Escape for autocomplete dropdown. + // 3. searchKeymap — Ctrl+F / Ctrl+H / F3 etc. + // 4. indentWithTab + defaultKeymap + historyKeymap — CM6 defaults last. + // indentWithTab makes Tab indent / Shift-Tab dedent (faithful to + // NotepadNext); it sits BELOW completionKeymap's Prec.high Tab, so when + // the autocomplete popup is open Tab accepts the completion, and when it + // is closed Tab indents instead of moving focus out of the editor. + // 5. editKeymapCompartment — injected by App.ts (Ctrl+/, Alt+Down …). + // Registered at normal precedence so it doesn't override the above. + // search() initializes the searchState StateField so setSearchQuery / findNext / + // findPrevious / replaceAll work without requiring the CM6 search panel to be + // opened first (P6.2 Find dialog uses these commands directly). + search(), + Prec.high(keymap.of([...closeBracketsKeymap, ...completionKeymap])), + keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]), + editKeymapCompartment.of([]), + // Notepad++ light theme base rules (bg/caret/gutter/selection). + // Included here so the initial EditorView state (before controller.setSharedExtensions + // creates the first per-doc state) renders with the correct light theme immediately. + // Per-doc states seed the controller-owned themeCompartment with the active + // theme MARKER (default: notepadLightMarker) so new tabs inherit the theme. + // Only the base CSS (both &light and &dark scopes) lives here; the marker in + // the compartment decides which scope fires — avoiding a light/dark conflict. + notepadBase, + notepadHighlight, + EditorView.updateListener.of((update: ViewUpdate) => { + // Route to this pane's controller which writes to the DocumentStore. + ctrlRef.current?.onUpdate(update); + // Emit a custom event for cursor tracking in StatusBar, tagged with the + // originating pane so the StatusBar can reflect only the focused view. + if (update.selectionSet) { + const pos = update.state.selection.main.head; + const line = update.state.doc.lineAt(pos); + update.view.dom.dispatchEvent( + new CustomEvent('cm-cursor-change', { + bubbles: true, + detail: { line: line.number, col: pos - line.from + 1, view: viewId }, + }), + ); + } + }), + ]; +} + +const sharedExtensions = buildSharedExtensions(controllerRef, 0); const view = new EditorView({ parent: document.getElementById('editor')!, @@ -311,6 +328,57 @@ const store = new DocumentStore(); const controller = new EditorController(view, store); controllerRef.current = controller; +// Focused-pane refs shared with App: App repoints these on focus change so every +// editor command, the Lua bridge, and the inspector panels follow the focused pane. +const focusedViewRef: { current: EditorView } = { current: view }; +const focusedControllerRef: { current: EditorController } = { current: controller }; + +/** + * Build the secondary (split) editor host. Two-phase: the host DOM (tab strip + + * editor container) is created immediately so it can be mounted into the dockview + * secondary group; the EditorView + EditorController are created by mount() only + * AFTER the host is attached to the dock (creating the view beforehand breaks + * dockview group insertion and mis-measures CM6). Called lazily by App. + */ +function createSecondaryEditor(): SecondaryEditorHost { + const hostEl = document.createElement('div'); + hostEl.id = 'dock-editor-host-2'; + hostEl.style.cssText = + 'display:flex;flex-direction:column;height:100%;width:100%;overflow:hidden;'; + + const tabbarWrapper = document.createElement('div'); + tabbarWrapper.style.cssText = 'flex:0 0 auto;'; + const tabbarEl = document.createElement('div'); + tabbarEl.id = 'tabbar-2'; + tabbarWrapper.appendChild(tabbarEl); + + const editorWrapper = document.createElement('div'); + editorWrapper.style.cssText = 'flex:1 1 auto;overflow:hidden;position:relative;min-height:0;'; + const editorEl = document.createElement('div'); + editorEl.id = 'editor-2'; + editorWrapper.appendChild(editorEl); + + hostEl.appendChild(tabbarWrapper); + hostEl.appendChild(editorWrapper); + + return { + hostEl, + tabbarEl, + mount() { + const sharedB = buildSharedExtensions(controllerBRef, 1); + const viewB = new EditorView({ + parent: editorEl, + state: EditorState.create({ doc: '', extensions: sharedB }), + }); + const controllerB = new EditorController(viewB, store); + controllerBRef.current = controllerB; + controllerB.setSharedExtensions(sharedB); + controllerB.attachScrollListener(); + return { view: viewB, controller: controllerB }; + }, + }; +} + // Pass the shared extensions (including updateListener) to the controller so // every per-document EditorState created by showDoc() carries the updateListener. // Without this, view.setState() silently drops the listener and edits are never @@ -429,6 +497,11 @@ const app = new App({ // symbolCompartment is intentionally NOT passed here — App now uses // controller.setSymbolExt() / controller.symbolCompartment so that // new tabs opened after a Show-Symbol toggle inherit the current setting. + // Split-view wiring: focused refs + dock + secondary-editor factory. + viewRef: focusedViewRef, + controllerRef: focusedControllerRef, + dockManager, + createSecondaryEditor, }); // ── Expose dockManager + panel toggles globally for App to wire up menu ────── @@ -560,8 +633,8 @@ dockManager.registerPanel({ render: (el: HTMLElement) => mountWorkspacePanel(el, store, controller), }); -// viewRef: mutable wrapper so the inspector panels always read the live EditorView. -const viewRef: { current: EditorView | null } = { current: view }; +// Inspector panels read the FOCUSED view so they follow the active split pane. +const viewRef = focusedViewRef; dockManager.registerPanel({ id: 'editor-inspector', @@ -591,8 +664,8 @@ dockManager.registerPanel({ render: (el: HTMLElement) => mountSearchResultsPanel(el, store, controller), }); -// Wire the editor bridge so Lua scripts can manipulate the editor via `editor.*` APIs. -luaConsoleEngine.setEditorBridge(createEditorBridge(() => view)); +// Wire the editor bridge so Lua scripts manipulate the FOCUSED editor pane. +luaConsoleEngine.setEditorBridge(createEditorBridge(() => focusedViewRef.current)); // __appReady resolves after app.start() + dockview init + a rAF so CM6 has // laid out at least once before e2e consumers poll for editor state. @@ -607,6 +680,9 @@ window.__appReady = (async () => { await dockManager.init(dockEl, editorEl, tabbarEl, dockStorage); + // Recreate a persisted split pane's dock group now that the dock is initialised. + app.finishStartup(); + // Request a CM6 measure so it picks up its new container size. view.requestMeasure(); diff --git a/src/services/document-store.test.ts b/src/services/document-store.test.ts index 59ece34..a11919e 100644 --- a/src/services/document-store.test.ts +++ b/src/services/document-store.test.ts @@ -40,3 +40,77 @@ describe('DocumentStore', () => { expect(store.activeId).toBeNull(); }); }); + +describe('DocumentStore — split views', () => { + it('new docs default to view 0 and the focused view is 0', () => { + const store = new DocumentStore(); + const a = store.create(); + expect(store.get(a.id)?.view ?? 0).toBe(0); + expect(store.focusedView()).toBe(0); + expect(store.listForView(0)).toHaveLength(1); + expect(store.listForView(1)).toHaveLength(0); + expect(store.hasView(1)).toBe(false); + }); + + it('listForView returns only that view’s docs, preserving order', () => { + const store = new DocumentStore(); + const a = store.create(); + const b = store.create(); + const c = store.create(); + store.moveToView(b.id, 1); + expect(store.listForView(0).map((d) => d.id)).toEqual([a.id, c.id]); + expect(store.listForView(1).map((d) => d.id)).toEqual([b.id]); + expect(store.hasView(1)).toBe(true); + }); + + it('moveToView reassigns the doc, focuses the target view, and sets it active there', () => { + const store = new DocumentStore(); + const a = store.create(); + const b = store.create(); + store.setActive(a.id); // focus view 0, active a + store.moveToView(a.id, 1); + expect(store.get(a.id)?.view).toBe(1); + expect(store.focusedView()).toBe(1); + expect(store.activeForView(1)?.id).toBe(a.id); + // view 0 re-points its active to the remaining neighbor (b) + expect(store.activeForView(0)?.id).toBe(b.id); + }); + + it('activeId / active() follow the focused view', () => { + const store = new DocumentStore(); + const a = store.create(); + const b = store.create(); + store.moveToView(b.id, 1); // b now in view 1, view 1 focused + active + expect(store.activeId).toBe(b.id); + expect(store.active()?.id).toBe(b.id); + store.setFocusedView(0); + expect(store.activeId).toBe(a.id); + expect(store.active()?.id).toBe(a.id); + }); + + it('remove re-points the neighbor within the same view only', () => { + const store = new DocumentStore(); + const a = store.create(); // view 0 + const b = store.create(); // view 0 + const c = store.create(); // view 0 -> move to view 1 + const d = store.create(); // view 0 -> move to view 1 + store.moveToView(c.id, 1); + store.moveToView(d.id, 1); + // view 1 has [c, d], active d (last move focuses+activates d) + store.setActiveForView(1, c.id); + store.remove(c.id); + // neighbor within view 1 is d, view 0 active untouched + expect(store.activeForView(1)?.id).toBe(d.id); + expect(store.listForView(0).map((x) => x.id)).toEqual([a.id, b.id]); + }); + + it('setFocusedView and setActiveForView notify subscribers', () => { + const store = new DocumentStore(); + const a = store.create(); + let calls = 0; + store.subscribe(() => calls++); + store.setFocusedView(1); + store.setActiveForView(0, a.id); + expect(calls).toBe(2); + }); +}); diff --git a/src/services/document-store.ts b/src/services/document-store.ts index 8c6b9b5..aa157ea 100644 --- a/src/services/document-store.ts +++ b/src/services/document-store.ts @@ -16,17 +16,48 @@ export interface Doc { diskModified?: number; /** true when the on-disk file was modified by another program since we last synced. */ externallyChanged?: boolean; + /** + * Which editor pane this document belongs to for split view. + * 0 = primary (default), 1 = secondary. Undefined is treated as 0 so old + * sessions (persisted before split view existed) restore into the primary pane. + */ + view?: ViewId; } +/** Editor pane index: 0 = primary, 1 = secondary. */ +export type ViewId = 0 | 1; + export class DocumentStore { private docs = new Map(); private order: DocId[] = []; - private _activeId: DocId | null = null; + /** + * Active document id PER view. Index 0 = primary pane, 1 = secondary pane. + * The single-view accessors (activeId / active / setActive) operate on the + * currently focused view so existing callers keep working unchanged. + */ + private _activeId: [DocId | null, DocId | null] = [null, null]; + /** Which pane is currently focused (drives activeId / active()). */ + private _focusedView: ViewId = 0; + /** Split orientation while a secondary pane exists; null when single-view. */ + private _splitOrientation: 'h' | 'v' | null = null; private listeners = new Set<() => void>(); private untitledCounterSeed = 0; + /** Persisted split orientation ('h' stacked / 'v' side-by-side), or null. */ + get splitOrientation(): 'h' | 'v' | null { + return this._splitOrientation; + } + setSplitOrientation(o: 'h' | 'v' | null): void { + this._splitOrientation = o; + } + + /** Focused-view-aware: the active doc id of the currently focused pane. */ get activeId(): DocId | null { - return this._activeId; + return this._activeId[this._focusedView]; + } + + private _viewOf(id: DocId): ViewId { + return (this.docs.get(id)?.view ?? 0) as ViewId; } subscribe(fn: () => void): () => void { @@ -55,9 +86,14 @@ export class DocumentStore { } add(doc: Doc): void { - this.docs.set(doc.id, doc); - this.order.push(doc.id); - this._activeId = doc.id; + // New docs without an explicit pane land in the currently focused view. + // Session restore passes docs that already carry a `view` tag, so it is + // preserved; docs from old sessions (no tag) default to the primary pane. + const view: ViewId = (doc.view ?? this._focusedView) as ViewId; + const stored: Doc = { ...doc, view }; + this.docs.set(stored.id, stored); + this.order.push(stored.id); + this._activeId[view] = stored.id; this.emit(); } @@ -69,6 +105,18 @@ export class DocumentStore { return this.order.map((id) => this.docs.get(id)!).filter(Boolean); } + /** Docs belonging to a given pane, in tab order. */ + listForView(view: ViewId): Doc[] { + return this.order + .map((id) => this.docs.get(id)!) + .filter((d) => d && ((d.view ?? 0) as ViewId) === view); + } + + /** True if any document currently lives in the given pane. */ + hasView(view: ViewId): boolean { + return this.order.some((id) => this._viewOf(id) === view); + } + update(id: DocId, patch: Partial): void { const doc = this.docs.get(id); if (!doc) return; @@ -76,25 +124,81 @@ export class DocumentStore { this.emit(); } + /** + * Focused-view-aware: make `id` active in the pane it belongs to, and focus + * that pane. Preserves single-view semantics for existing callers. + */ setActive(id: DocId): void { - if (this.docs.has(id)) { - this._activeId = id; - this.emit(); - } + if (!this.docs.has(id)) return; + const view = this._viewOf(id); + this._activeId[view] = id; + this._focusedView = view; + this.emit(); } active(): Doc | undefined { - return this._activeId ? this.docs.get(this._activeId) : undefined; + const id = this._activeId[this._focusedView]; + return id ? this.docs.get(id) : undefined; + } + + /** The currently focused pane. */ + focusedView(): ViewId { + return this._focusedView; + } + + /** Set which pane is focused (drives activeId / active()). */ + setFocusedView(view: ViewId): void { + this._focusedView = view; + this.emit(); + } + + /** The active document of a specific pane (independent of focus). */ + activeForView(view: ViewId): Doc | undefined { + const id = this._activeId[view]; + return id ? this.docs.get(id) : undefined; + } + + /** Set the active document of a specific pane without changing focus. */ + setActiveForView(view: ViewId, id: DocId): void { + if (!this.docs.has(id)) return; + this._activeId[view] = id; + this.emit(); + } + + /** + * Move a document to another pane. Re-points the source pane's active tab to + * a neighbor within that same pane, makes the doc active in (and focuses) the + * target pane. No-op if the doc is already in `view`. + */ + moveToView(id: DocId, view: ViewId): void { + const doc = this.docs.get(id); + if (!doc) return; + const from = (doc.view ?? 0) as ViewId; + if (from === view) return; + // Re-point the source pane's active tab BEFORE reassigning, using a + // neighbor within the source pane (next, else previous). + if (this._activeId[from] === id) { + const viewOrder = this.order.filter((d) => this._viewOf(d) === from); + const vi = viewOrder.indexOf(id); + this._activeId[from] = viewOrder[vi + 1] ?? viewOrder[vi - 1] ?? null; + } + this.docs.set(id, { ...doc, view }); + this._activeId[view] = id; + this._focusedView = view; + this.emit(); } remove(id: DocId): void { const idx = this.order.indexOf(id); if (idx === -1) return; + const view = this._viewOf(id); + // Compute the same-pane neighbor before splicing. + const viewOrder = this.order.filter((d) => this._viewOf(d) === view); + const vi = viewOrder.indexOf(id); this.docs.delete(id); this.order.splice(idx, 1); - if (this._activeId === id) { - const neighbor = this.order[idx] ?? this.order[idx - 1] ?? null; - this._activeId = neighbor; + if (this._activeId[view] === id) { + this._activeId[view] = viewOrder[vi + 1] ?? viewOrder[vi - 1] ?? null; } this.emit(); } diff --git a/src/services/persistence-service.ts b/src/services/persistence-service.ts index d15330d..152f96b 100644 --- a/src/services/persistence-service.ts +++ b/src/services/persistence-service.ts @@ -12,7 +12,12 @@ export interface SavedMacro { export type PersistedDoc = Doc; export interface SessionSnapshot { docs: PersistedDoc[]; + /** Focused-view active doc id (kept for back-compat with pre-split sessions). */ activeId: DocId | null; + /** Per-view active doc ids [primary, secondary]. Optional for old snapshots. */ + activeIds?: [DocId | null, DocId | null]; + /** Split orientation when a secondary pane is present ('h' stacked, 'v' side-by-side). */ + splitOrientation?: 'h' | 'v' | null; } export interface SearchPrefs { diff --git a/src/styles.css b/src/styles.css index 962c19d..38df053 100644 --- a/src/styles.css +++ b/src/styles.css @@ -40,7 +40,8 @@ body { position: relative; overflow: hidden; } -#editor { +#editor, +#editor-2 { width: 100%; height: 100%; /* Bound the height so CM6's .cm-editor can fill it and scroll internally. */ @@ -89,7 +90,8 @@ body { } /* ── Tab bar ──────────────────────────────────────────────────────────────── */ -#tabbar { +#tabbar, +#tabbar-2 { display: flex; overflow: hidden; background: rgb(192, 192, 192); @@ -255,7 +257,7 @@ body { } /* ── New-tab button ─────────────────────────────────────────────────────────── */ -#tab-new { +.tab-new-btn { background: none; border: none; color: #444; @@ -266,7 +268,7 @@ body { align-self: center; } -#tab-new:hover { +.tab-new-btn:hover { color: #000; } diff --git a/tests/e2e/split-view.spec.ts b/tests/e2e/split-view.spec.ts new file mode 100644 index 0000000..b80983b --- /dev/null +++ b/tests/e2e/split-view.spec.ts @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +/** + * E2E tests for faithful Notepad++-style split view (two editor panes). + * + * Verifies: + * - View → Split Vertical creates a second pane (#editor-2 + #tabbar-2) and + * moves the current document into it. + * - Each pane edits independently. + * - Right-click a tab → "Move to Other View" relocates it. + * - Clicking a pane makes it focused (status bar Ln/Col follows it). + * - The split (and per-pane tabs) survive a page reload. + */ +import { test, expect } from '@playwright/test'; + +type Win = Window & { __appReady: unknown }; + +async function clearStorageAndReload(page: Parameters[1]>[0]['page']) { + page.on('dialog', (d) => void d.accept()); + await page.goto('/editor.html'); + await page.evaluate(async () => { + const dbs = await indexedDB.databases(); + await Promise.all( + dbs.map( + (db) => + new Promise((res, rej) => { + const req = indexedDB.deleteDatabase(db.name!); + req.onsuccess = () => res(); + req.onerror = () => rej(req.error); + }), + ), + ); + }); + await page.reload(); + await page.waitForFunction(() => (window as unknown as Win).__appReady !== undefined); + await page.evaluate(() => (window as unknown as Win).__appReady); +} + +async function splitVertical(page: Parameters[1]>[0]['page']) { + await page.getByRole('menuitem', { name: 'View' }).click(); + await page.getByRole('menuitem', { name: 'Split Vertical' }).click(); +} + +test.describe('Split view', () => { + test('View → Split Vertical creates a second editor pane', async ({ page }) => { + await clearStorageAndReload(page); + await expect(page.locator('#editor-2')).toHaveCount(0); + + await splitVertical(page); + + await expect(page.locator('#editor-2')).toHaveCount(1); + await expect(page.locator('#tabbar-2')).toHaveCount(1); + // Two CM6 editors are now mounted. + await expect(page.locator('.cm-editor')).toHaveCount(2); + }); + + test('panes edit independently', async ({ page }) => { + await clearStorageAndReload(page); + // Type into the primary pane first. + const primary = page.locator('#editor .cm-content'); + await primary.click(); + await page.keyboard.type('PRIMARY'); + + // Split: the current doc moves to the secondary pane. + await splitVertical(page); + + // The secondary pane now holds "PRIMARY"; the primary pane got a fresh doc. + const secondary = page.locator('#editor-2 .cm-content'); + await expect(secondary).toContainText('PRIMARY'); + + // Type into the (now empty) primary pane and confirm the secondary is unaffected. + await primary.click(); + await page.keyboard.type('LEFT'); + await expect(page.locator('#editor .cm-content')).toContainText('LEFT'); + await expect(secondary).toContainText('PRIMARY'); + await expect(secondary).not.toContainText('LEFT'); + }); + + test('Move to Other View relocates a tab and collapses when a pane empties', async ({ page }) => { + await clearStorageAndReload(page); + await splitVertical(page); // now split, doc moved to secondary, fresh doc in primary + + // Right-click the secondary pane's tab → Move to Other View. + await page.locator('#tabbar-2 .tab').first().click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Move to Other View' }).click(); + + // Secondary pane emptied → split collapses back to a single pane. + await expect(page.locator('#editor-2')).toHaveCount(0); + }); + + test('split layout survives reload', async ({ page }) => { + await clearStorageAndReload(page); + await splitVertical(page); + await expect(page.locator('#editor-2')).toHaveCount(1); + + // Give the debounced session save time to flush, then reload. + await page.waitForTimeout(800); + await page.reload(); + await page.waitForFunction(() => (window as unknown as Win).__appReady !== undefined); + await page.evaluate(() => (window as unknown as Win).__appReady); + + // The secondary pane is restored. + await expect(page.locator('#editor-2')).toHaveCount(1); + await expect(page.locator('.cm-editor')).toHaveCount(2); + }); +});