diff --git a/CLAUDE.md b/CLAUDE.md index 565d682..99be1c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,3 @@ # Roject -See **[workspace/outline/index.html](workspace/outline/index.html)** for the full developer documentation β€” project outline, tech stack, coding conventions, and repeatable actions. +See **[workspace/index.html](workspace/index.html)** for the full developer documentation β€” project outline, tech stack, coding conventions, and repeatable actions. diff --git a/source/components/code-panel/code-panel.ts b/source/components/code-panel/code-panel.ts index 0aaeae2..3228d94 100644 --- a/source/components/code-panel/code-panel.ts +++ b/source/components/code-panel/code-panel.ts @@ -1,4 +1,5 @@ import { Editor } from '../../editor/Editor.js'; +import { EditorPanelDefinition, FileEditorPanelDefinition } from '../../editor/editor-panel.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; import { csharpMode } from './CSharpMode.js'; @@ -9,7 +10,9 @@ CodeMirror.defineMode( 'rokojori-cs', () => csharpMode.cmMode ); class CodePanel extends HTMLElement { + __interfaces__ = [ EditorPanelDefinition.type, FileEditorPanelDefinition.type ]; currentPath: string | null = null; + _dirty = false; _pinned: boolean = false; _cm: any = null; _initialized = false; @@ -132,8 +135,14 @@ class CodePanel extends HTMLElement this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label: 'πŸ“ ' + name } } ) ); } + hasUnsavedChanges(): boolean + { + return this._dirty; + } + _updateButtons( dirty: boolean ): void { + this._dirty = dirty; ( this.querySelector( '.cp-save' ) as HTMLButtonElement ).disabled = ! dirty; ( this.querySelector( '.cp-undo' ) as HTMLButtonElement ).disabled = this._cm.historySize().undo < 1; ( this.querySelector( '.cp-redo' ) as HTMLButtonElement ).disabled = this._cm.historySize().redo < 1; diff --git a/source/components/editor-shell/editor-shell.ts b/source/components/editor-shell/editor-shell.ts index 7c80097..6d37855 100644 --- a/source/components/editor-shell/editor-shell.ts +++ b/source/components/editor-shell/editor-shell.ts @@ -2,6 +2,21 @@ import { Editor } from '../../editor/Editor.js'; // ── Layout helpers ──────────────────────────────────────────────────────────── +function removeAdjacentHandle( el: HTMLElement, handleClass: string ): void +{ + const next = el.nextElementSibling as HTMLElement | null; + if ( next && next.classList.contains( handleClass ) ) + { + next.remove(); + return; + } + const prev = el.previousElementSibling as HTMLElement | null; + if ( prev && prev.classList.contains( handleClass ) ) + { + prev.remove(); + } +} + let tcCounter = 0; function nextTcId(): string { return `tc-${++tcCounter}`; } @@ -151,16 +166,51 @@ class EditorShell extends HTMLElement { private setupSplitListener(): void { this.addEventListener('tab-container:split', (e: Event) => { - const { containerId } = (e as CustomEvent).detail as { containerId: string }; + const { containerId, direction } = (e as CustomEvent).detail as { containerId: string; direction: 'horizontal' | 'vertical' }; const tc = document.getElementById(containerId); if (!tc) return; const section = tc.closest('.es-section') as HTMLElement | null; - const sections = tc.closest('.es-sections') as HTMLElement | null; - if (!section || !sections) return; - const newSec = makeSection(); - const handle = makeResizeHandle('v'); - sections.insertBefore(handle, section.nextSibling); - sections.insertBefore(newSec, handle.nextSibling); + if (!section) return; + + if ( 'vertical' === direction ) + { + const newTc = document.createElement('tab-container') as HTMLElement; + newTc.id = nextTcId(); + const handle = makeResizeHandle('h'); + section.appendChild(handle); + section.appendChild(newTc); + } + else + { + const sections = section.closest('.es-sections') as HTMLElement | null; + if (!sections) return; + const newSec = makeSection(); + const handle = makeResizeHandle('v'); + sections.insertBefore(handle, section.nextSibling); + sections.insertBefore(newSec, handle.nextSibling); + } + }); + + this.addEventListener('tab-container:close-container', (e: Event) => { + const { containerId } = (e as CustomEvent).detail as { containerId: string }; + const tc = document.getElementById(containerId) as HTMLElement | null; + if (!tc) return; + const section = tc.closest('.es-section') as HTMLElement | null; + if (!section) return; + + const tcsInSection = section.querySelectorAll(':scope > tab-container'); + if ( tcsInSection.length > 1 ) + { + removeAdjacentHandle(tc, 'es-h-handle'); + tc.remove(); + } + else + { + const sections = section.closest('.es-sections') as HTMLElement | null; + if (!sections) return; + removeAdjacentHandle(section, 'es-v-handle'); + section.remove(); + } }); this.addEventListener('tab-container:add-panel', (e: Event) => { @@ -245,15 +295,21 @@ class EditorShell extends HTMLElement { private setupResizeHandler(): void { const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; - const obs = new ResizeObserver( () => + + const workspaceObs = new ResizeObserver( () => { this._redistributeFlex( workspace, '.es-panel' ); - workspace.querySelectorAll( '.es-sections' ).forEach( container => - { - this._redistributeFlex( container as HTMLElement, '.es-section' ); - } ); } ); - obs.observe( workspace ); + workspaceObs.observe( workspace ); + + const sectionsObs = new ResizeObserver( ( entries ) => + { + for ( const entry of entries ) + { + this._redistributeFlex( entry.target as HTMLElement, '.es-section' ); + } + } ); + workspace.querySelectorAll( '.es-sections' ).forEach( s => sectionsObs.observe( s ) ); } private _redistributeFlex( container: HTMLElement, childSelector: string ): void @@ -293,7 +349,15 @@ class EditorShell extends HTMLElement { const apply = ( portrait: boolean ) => { this.classList.toggle( 'portrait', portrait ); - if ( portrait ) this.showPortraitPanel( this.activePortraitPanel ); + if ( portrait ) + { + this.showPortraitPanel( this.activePortraitPanel ); + } + else + { + this.querySelectorAll( '.es-panel' ).forEach( p => { ( p as HTMLElement ).style.display = ''; } ); + this.querySelectorAll( '.es-v-handle' ).forEach( h => { ( h as HTMLElement ).style.display = ''; } ); + } }; btns.querySelectorAll( '.es-pb-btn' ).forEach( btn => { diff --git a/source/components/file-tree-panel/file-tree-panel.ts b/source/components/file-tree-panel/file-tree-panel.ts index a19f57b..a4351fe 100644 --- a/source/components/file-tree-panel/file-tree-panel.ts +++ b/source/components/file-tree-panel/file-tree-panel.ts @@ -1,4 +1,5 @@ import { Editor } from '../../editor/Editor.js'; +import { EditorPanelDefinition } from '../../editor/editor-panel.js'; import { ContextMenuDirectory, ContextMenuEntry, ContextMenuReadOnlyEntry, ContextMenuSeparator } from '../context-menu/context-menu.js'; import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js'; @@ -10,6 +11,7 @@ interface FileNode { } class FileTreePanel extends HTMLElement { + __interfaces__ = [ EditorPanelDefinition.type ]; selectedPath: string | null = null; _rootPath: string = ''; _initialized = false; diff --git a/source/components/page-editor-panel/page-editor-panel.ts b/source/components/page-editor-panel/page-editor-panel.ts index 5e68806..eda5490 100644 --- a/source/components/page-editor-panel/page-editor-panel.ts +++ b/source/components/page-editor-panel/page-editor-panel.ts @@ -1,4 +1,5 @@ import { Editor } from '../../editor/Editor.js'; +import { EditorPanelDefinition, FileEditorPanelDefinition } from '../../editor/editor-panel.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; // ── Block registry ──────────────────────────────────────────────────────────── @@ -191,7 +192,9 @@ function wrapSelection( doc: Document, range: Range, tagName: string, attributes class PageEditorPanel extends HTMLElement { + __interfaces__ = [ EditorPanelDefinition.type, FileEditorPanelDefinition.type ]; currentPath: string | null = null; + _dirty = false; _pinned: boolean = false; _iframe: HTMLIFrameElement | null = null; _undoStack: string[] = []; @@ -477,8 +480,14 @@ class PageEditorPanel extends HTMLElement dir.add( new ContextMenuReadOnlyEntry( dir, this.currentPath ? `Editing: ${ this.currentPath }` : 'No document open' ) ); } + hasUnsavedChanges(): boolean + { + return this._dirty; + } + _updateButtons( dirty: boolean ): void { + this._dirty = dirty; ( this.querySelector( '.pep-save' ) as HTMLButtonElement ).disabled = ! dirty; ( this.querySelector( '.pep-undo' ) as HTMLButtonElement ).disabled = this._undoStack.length < 2; ( this.querySelector( '.pep-redo' ) as HTMLButtonElement ).disabled = this._redoStack.length === 0; diff --git a/source/components/rojo-chat-panel/rojo-chat-panel.ts b/source/components/rojo-chat-panel/rojo-chat-panel.ts index 54a15a8..437ede0 100644 --- a/source/components/rojo-chat-panel/rojo-chat-panel.ts +++ b/source/components/rojo-chat-panel/rojo-chat-panel.ts @@ -1,4 +1,5 @@ import { Editor } from '../../editor/Editor.js'; +import { EditorPanelDefinition } from '../../editor/editor-panel.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; declare const markdownit: ( options?: Record ) => { render: ( md: string ) => string }; @@ -39,6 +40,7 @@ function parentDir( filePath: string ): string class RojoChatPanel extends HTMLElement { + __interfaces__ = [ EditorPanelDefinition.type ]; _initialized = false; _conversationId = ''; _sending = false; diff --git a/source/components/rojo-settings-panel/rojo-settings-panel.ts b/source/components/rojo-settings-panel/rojo-settings-panel.ts index bef5a75..4c0ae6f 100644 --- a/source/components/rojo-settings-panel/rojo-settings-panel.ts +++ b/source/components/rojo-settings-panel/rojo-settings-panel.ts @@ -1,4 +1,5 @@ import { Editor } from '../../editor/Editor.js'; +import { EditorPanelDefinition } from '../../editor/editor-panel.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; const NS_INKSCAPE = 'http://www.inkscape.org/namespaces/inkscape'; @@ -111,6 +112,7 @@ async function loadPortraitSvg(): Promise class RojoSettingsPanel extends HTMLElement { + __interfaces__ = [ EditorPanelDefinition.type ]; currentPath: string | null = null; _initialized = false; _ignoreChange = false; diff --git a/source/components/tab-container/tab-container.ts b/source/components/tab-container/tab-container.ts index bdfc689..7a5e6bd 100644 --- a/source/components/tab-container/tab-container.ts +++ b/source/components/tab-container/tab-container.ts @@ -1,18 +1,17 @@ import { Editor } from '../../editor/Editor.js'; +import { EditorPanel, EditorPanelDefinition, FileEditorPanel, FileEditorPanelDefinition, implementsInterface } from '../../editor/editor-panel.js'; import { ContextMenuDirectory, ContextMenuEntry, ContextMenuSeparator } from '../context-menu/context-menu.js'; +import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js'; interface TabEntry { id: string; label: string; panelType: string; element: HTMLElement; - dirty: boolean; factory: () => HTMLElement; } -export interface EditorPanel extends HTMLElement { - addContextMenuEntries(dir: ContextMenuDirectory): void; -} +export { EditorPanel }; class TabContainer extends HTMLElement { tabs: TabEntry[] = []; @@ -54,17 +53,8 @@ class TabContainer extends HTMLElement { if ( tab ) { tab.label = ( e as CustomEvent ).detail.label; this.renderBar(); } } ); - Editor.get().onDocumentDirty.addListener( ( e ) => - { - const tab = this.tabs.find( t => ( t.element as any ).currentPath === e.path ); - if ( tab ) { tab.dirty = true; this.renderBar(); } - } ); - - Editor.get().onDocumentSaved.addListener( ( e ) => - { - const tab = this.tabs.find( t => ( t.element as any ).currentPath === e.path ); - if ( tab ) { tab.dirty = false; this.renderBar(); } - } ); + Editor.get().onDocumentDirty.addListener( () => this.renderBar() ); + Editor.get().onDocumentSaved.addListener( () => this.renderBar() ); } openMenu(e: MouseEvent): void { @@ -91,9 +81,26 @@ class TabContainer extends HTMLElement { root.add(addDir); root.add(new ContextMenuEntry(root, 'Duplicate', () => this.duplicateActive())); - root.add(new ContextMenuEntry(root, 'Split', () => { - this.dispatchEvent(new CustomEvent('tab-container:split', { bubbles: true, detail: { containerId: this.id } })); + + const splitDir = new ContextMenuDirectory(root, 'Split'); + splitDir.add(new ContextMenuEntry(splitDir, '↔ Horizontally', () => { + this.dispatchEvent(new CustomEvent('tab-container:split', { + bubbles: true, + detail: { containerId: this.id, direction: 'horizontal' }, + })); })); + splitDir.add(new ContextMenuEntry(splitDir, '↕ Vertically', () => { + this.dispatchEvent(new CustomEvent('tab-container:split', { + bubbles: true, + detail: { containerId: this.id, direction: 'vertical' }, + })); + })); + root.add(splitDir); + + const panelCount = this.closest('.es-panel')?.querySelectorAll('tab-container').length ?? 1; + if (panelCount > 1) { + root.add(new ContextMenuEntry(root, 'Close Container', () => this.closeContainer())); + } const activeTab = this.tabs.find(t => t.id === this.activeId); if (activeTab) { @@ -110,6 +117,32 @@ class TabContainer extends HTMLElement { root.show(rect.left, rect.bottom); } + async closeContainer(): Promise + { + const hasUnsaved = this.tabs.some( t => + implementsInterface( t.element, FileEditorPanelDefinition ) && + ( t.element as FileEditorPanel ).hasUnsavedChanges() + ); + + if ( hasUnsaved ) + { + const confirmed = await showConfirmDialog( { + title: 'Close Container', + message: 'Some tabs have unsaved changes.', + confirmLabel: 'Close Without Saving', + cancelLabel: "Don't Close", + danger: true, + } ); + + if ( !confirmed ) return; + } + + this.dispatchEvent( new CustomEvent( 'tab-container:close-container', { + bubbles: true, + detail: { containerId: this.id }, + } ) ); + } + duplicateActive(): void { if ( ! this.activeId ) @@ -128,23 +161,22 @@ class TabContainer extends HTMLElement { this.addTab( { id: newId, label: tab.label, panelType: tab.panelType }, tab.factory ); } - closeActive(): void + closeActive(): void { - if ( ! this.activeId ) + if ( ! this.activeId ) { - return; - + return; } this.extractTab( this.activeId ); } - addTab(config: { id: string; label: string; panelType: string }, factory: () => HTMLElement): void + addTab(config: { id: string; label: string; panelType: string }, factory: () => HTMLElement): void { const element = factory(); element.style.display = 'none'; this.querySelector('.tc-content')!.appendChild(element); - this.tabs.push({ ...config, element, dirty: false, factory }); + this.tabs.push({ ...config, element, factory }); this.activateTab(config.id); } @@ -176,15 +208,25 @@ class TabContainer extends HTMLElement { renderBar(): void { const bar = this.querySelector('.tc-tabs')!; - bar.innerHTML = this.tabs.map(t => ` -
- ${t.dirty ? '●' : ''}${t.label} -
- `).join(''); + bar.innerHTML = this.tabs.map(t => { + const unsaved = implementsInterface( t.element, FileEditorPanelDefinition ) && + ( t.element as FileEditorPanel ).hasUnsavedChanges(); + return ` +
+ ${unsaved ? '●' : ''}${t.label} +
+ `; + }).join(''); bar.querySelectorAll('.tc-tab').forEach(el => { el.addEventListener('click', () => this.activateTab((el as HTMLElement).dataset.tabId!)); + el.addEventListener('mousedown', (e: Event) => { + const me = e as MouseEvent; + if ( me.button !== 1 ) return; + me.preventDefault(); + this.extractTab( (el as HTMLElement).dataset.tabId! ); + }); el.addEventListener('dragstart', (e: Event) => { const de = e as DragEvent; const tabId = (el as HTMLElement).dataset.tabId!; diff --git a/source/editor/editor-panel.ts b/source/editor/editor-panel.ts new file mode 100644 index 0000000..22693d8 --- /dev/null +++ b/source/editor/editor-panel.ts @@ -0,0 +1,27 @@ +import { ContextMenuDirectory } from '../components/context-menu/context-menu.js'; + +export class EditorPanelDefinition +{ + static readonly type = 'EditorPanel'; +} + +export class FileEditorPanelDefinition +{ + static readonly type = 'FileEditorPanel'; +} + +export interface EditorPanel extends HTMLElement +{ + __interfaces__: string[]; + addContextMenuEntries( dir: ContextMenuDirectory ): void; +} + +export interface FileEditorPanel extends EditorPanel +{ + hasUnsavedChanges(): boolean; +} + +export function implementsInterface( el: any, def: { type: string } ): boolean +{ + return Array.isArray( el.__interfaces__ ) && el.__interfaces__.includes( def.type ); +} diff --git a/workspace/actions/update-outline/index.html b/workspace/actions/update-outline/index.html index f3beccc..fd6a600 100644 --- a/workspace/actions/update-outline/index.html +++ b/workspace/actions/update-outline/index.html @@ -80,8 +80,8 @@

Step 2 β€” Check CLAUDE.md

Open CLAUDE.md at the project root and verify it still just points - to the outline. If it has drifted and contains duplicated or stale content, - trim it back so it only references workspace/outline/index.html. + to the workspace index. If it has drifted and contains duplicated or stale content, + trim it back so it only references workspace/index.html.

diff --git a/workspace/guides/writing-typescript-code/index.html b/workspace/guides/writing-typescript-code/index.html index 36329b2..4c151b0 100644 --- a/workspace/guides/writing-typescript-code/index.html +++ b/workspace/guides/writing-typescript-code/index.html @@ -159,6 +159,57 @@ from the shared library β€” not a plain function property or a custom event.

+ +
+ Structural markup β€” custom element names, not div.class-name +

+ Never write <div class="es-header"> or similar. Use a + hyphenated custom element name instead: <editor-shell-header>. + The element does not need to be registered with customElements.define + β€” it can be an anonymous container used purely as a CSS selector. This makes + the DOM tree readable in DevTools without decoding class names, and keeps + component markup self-documenting. +

+
/* Wrong */
+<div class="es-header">...</div>
+
+/* Right */
+<editor-shell-header>...</editor-shell-header>
+

CSS uses the element name as the root selector:

+
editor-shell-header {
+  display: flex;
+  height: 40px;
+}
+
+ +
+ Web Component interfaces β€” Definition classes +

+ Web Components must extend HTMLElement and cannot use class + inheritance for shared contracts β€” interfaces are the justified exception to + the "classes not interfaces" rule. Each interface has a companion Definition + class with a static readonly type string, and every implementing + class declares an __interfaces__ member listing its Definition types. + Use implementsInterface(el, SomeDefinition) for runtime checks β€” + never spread raw string literals. +

+
export class EditorPanelDefinition
+{
+  static readonly type = 'EditorPanel';
+}
+
+export interface EditorPanel extends HTMLElement
+{
+  __interfaces__: string[];
+  addContextMenuEntries( dir: ContextMenuDirectory ): void;
+}
+
+// implementing class:
+__interfaces__ = [ EditorPanelDefinition.type ];
+
+// runtime check:
+if ( implementsInterface( panel, EditorPanelDefinition ) ) { ... }
+
diff --git a/workspace/history/2026/07-July/25-Friday/index.html b/workspace/history/2026/07-July/25-Friday/index.html index 154f48a..616ab9d 100644 --- a/workspace/history/2026/07-July/25-Friday/index.html +++ b/workspace/history/2026/07-July/25-Friday/index.html @@ -158,6 +158,82 @@
+
+

Tab container updates

+ +
+

Split submenu + vertical split

+

+ The single Split menu entry is replaced by a Split > submenu + with ↔ Horizontally (adds a new section side by side in .es-sections) + and ↕ Vertically (adds a second tab-container + horizontal + resize handle inside the same .es-section). The + tab-container:split event now carries a direction field. + .es-section was already flex-direction: column, so no CSS + changes were needed for vertical split. +

+
+ +
+

Close Container

+

+ A Close Container context menu entry fires tab-container:close-container, + handled by editor-shell: if the container is in a vertical split (multiple + tab-container elements in the section), only that container and its adjacent + handle are removed; otherwise the whole section and its adjacent es-v-handle + are removed. The entry is hidden when the container is the last one in its + .es-panel slot. If any tab has unsaved changes, + showConfirmDialog prompts with Don't Close / + Close Without Saving before dispatching the event. +

+
+ +
+

Middle-mouse tab close

+

+ Tabs now close on middle-mouse click: a mousedown listener with + e.button === 1 calls e.preventDefault() (suppresses the + browser scroll cursor) and extracts the tab immediately. +

+
+ +
+

EditorPanel / FileEditorPanel interface system

+

+ New file source/editor/editor-panel.ts defines the panel contract. + EditorPanel requires __interfaces__: string[] and + addContextMenuEntries(). FileEditorPanel extends EditorPanel + adds hasUnsavedChanges(): boolean, replacing the old + TabEntry.dirty flag β€” renderBar() now queries the panel + live instead of caching a boolean. + Each interface has a companion Definition class with static readonly type; + implementsInterface(el, Def) is the single runtime check function. + All five panels (page-editor-panel, code-panel, + file-tree-panel, rojo-settings-panel, + rojo-chat-panel) updated with __interfaces__. +

+
+ +
+

Bug fixes

+

+ Section resize: setupResizeHandler previously observed + only the workspace element, so section redistribution never fired when a panel was + dragged (workspace size doesn't change on panel drag). Fixed by observing each + .es-sections element directly β€” when the panel narrows, its sections + container narrows too and the observer redistributes sections correctly. +

+

+ Portrait β†’ landscape: showPortraitPanel set inline + display: none on panels and handles, but returning to landscape only + removed the portrait class β€” the inline styles persisted and kept panels + hidden. Fixed by clearing style.display on all panels and + .es-v-handle elements when apply(false) runs. +

+
+ +
+

Key decisions

diff --git a/workspace/history/index.html b/workspace/history/index.html index 1a35830..ab64f3a 100644 --- a/workspace/history/index.html +++ b/workspace/history/index.html @@ -19,6 +19,11 @@

2026 β€” July

+
+

Friday, 25 July 2026

+

Tab container: split submenu (horizontal/vertical), close container with unsaved-changes dialog, middle-mouse tab close. EditorPanel/FileEditorPanel interface system replacing TabEntry.dirty. Section resize and portrait→landscape bug fixes. TypeScript guide: custom element names + web component interface pattern. CLAUDE.md and workspace index corrected.

+
+

Friday, 18 July 2026

CodeMirror syntax highlighting: vendor modes (clike, python, shell, yaml) + custom C# mode built on BrowserLexer + CodeMirrorLexerMode with dynamic keyword sets. File tree single-click smart open (focus existing, skip pinned). rokojori-auth login fix: removed broken refresh-session redirect. Electron local dev fixes: Bearer-before-cookie token extraction, JWT_CLOCK_TOLERANCE clock skew escape hatch, startup token refresh, quit-on-login race fix, credential persistence with remember-me. Mobile nav z-index fix.

diff --git a/workspace/index.html b/workspace/index.html index bb13426..4001500 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -85,9 +85,10 @@ Check Boards for what is active right now.

- Agents: read Outline β†’ Guides β†’ Reference before making changes. + Agents: read Outline β†’ Guides β†’ Actions β†’ Reference before making changes. Check Boards and the most recent History entry to understand the current context. Follow all coding conventions in the Guides β€” do not infer style from existing code alone. + After completing work, run the Update Outline and Update History actions.

diff --git a/workspace/outline/index.html b/workspace/outline/index.html index 3a43860..6bd24e1 100644 --- a/workspace/outline/index.html +++ b/workspace/outline/index.html @@ -185,6 +185,27 @@ available unpinned non-dirty editor of the correct type is used; a new panel is created in the active section if none qualifies. Pinned panels are never overwritten.

+

+ Tab container context menu: + Add > opens a panel-type submenu. Duplicate clones the active + tab. Split > offers ↔ Horizontally (new section side by side) + and ↕ Vertically (new tab-container stacked inside the same section). + Close Container removes the container and its adjacent resize handle; it is + hidden when the container is the last one in its slot. If any tab has unsaved changes, + a confirm dialog (Don't Close / Close Without Saving) appears first. + Tabs can also be closed by middle-mouse click. +

+

+ Panel interfaces β€” source/editor/editor-panel.ts defines + two interfaces for Web Component panels. EditorPanel (all panels) requires + __interfaces__: string[] and addContextMenuEntries(). + FileEditorPanel extends EditorPanel (file-editing panels only) adds + hasUnsavedChanges(): boolean, replacing the old TabEntry.dirty + flag. Each interface has a companion Definition class + (EditorPanelDefinition, FileEditorPanelDefinition) with a + static readonly type string; use implementsInterface(el, Def) + for runtime checks. +