Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
425 changes: 354 additions & 71 deletions src/app/app.ts

Large diffs are not rendered by default.

90 changes: 86 additions & 4 deletions src/app/dock-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ vi.mock('dockview-core', () => {
_panels: Map<string, { id: string; component: string; title: string }> = new Map();
_layoutChangeListeners: Array<() => void> = [];
_removePanelListeners: Array<(panel: { id: string }) => void> = [];
_activeGroupListeners: Array<(group: unknown) => void> = [];

constructor() {
/* no-op — container / opts ignored in tests */
Expand All @@ -26,20 +27,30 @@ 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<string, unknown> = 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: {
close() {
panels.delete(id);
},
},
group: {
element: document.createElement('div'),
locked: false,
},
group: this._groups.get(id),
};
}

Expand Down Expand Up @@ -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);
Expand All @@ -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 */
}
Expand Down Expand Up @@ -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]);
});
});
93 changes: 93 additions & 0 deletions src/app/dock-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ export class DockManager {
private _visiblePanels = new Set<string>();
/** 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`.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<void> {
if (!this._component || !this._storage) return;
try {
Expand Down
13 changes: 11 additions & 2 deletions src/app/file-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const res = await this.file.open();
if (!res) return;
Expand Down
2 changes: 2 additions & 0 deletions src/app/menu-bar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ function makeActions(overrides: Partial<MenuBarActions> = {}): MenuBarActions {
viewEditorInspector: noop,
viewLanguageInspector: noop,
viewLuaConsole: noop,
viewSplitHorizontal: noop,
viewSplitVertical: noop,
fileOpenFolder: noop,
langItems: [],
searchToggleBookmark: noop,
Expand Down
10 changes: 8 additions & 2 deletions src/app/menu-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,8 @@ export class MenuBar {
viewEditorInspector,
viewLanguageInspector,
viewLuaConsole,
viewSplitHorizontal,
viewSplitVertical,
langItems,
searchToggleBookmark,
searchNextBookmark,
Expand Down Expand Up @@ -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),
],
};

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/app/session-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}
Loading
Loading