mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
feat(split-pane): add open/close orchestration, picker, and divider drag
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
fa8bb13a27
commit
2abf328db8
@@ -31,6 +31,7 @@ export const BROWSER_TEST_GLOBS = [
|
|||||||
'test/capture-geometry-retry.browser.test.ts',
|
'test/capture-geometry-retry.browser.test.ts',
|
||||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||||
'test/split-pane-terminal.browser.test.ts',
|
'test/split-pane-terminal.browser.test.ts',
|
||||||
|
'test/split-pane-orchestration.browser.test.ts',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -118,3 +118,126 @@
|
|||||||
|
|
||||||
global.SplitTerminalPane = SplitTerminalPane;
|
global.SplitTerminalPane = SplitTerminalPane;
|
||||||
})(window);
|
})(window);
|
||||||
|
|
||||||
|
Object.assign(CodemanApp.prototype, {
|
||||||
|
openSplitPicker() {
|
||||||
|
if (this._splitPane) {
|
||||||
|
this.closeSplitPane();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const candidates = window.CodemanSplitPane.buildSplitPickerSessions(
|
||||||
|
this.sessions,
|
||||||
|
this.sessionOrder,
|
||||||
|
this.activeSessionId
|
||||||
|
);
|
||||||
|
const existing = document.getElementById('splitPickerMenu');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.id = 'splitPickerMenu';
|
||||||
|
menu.className = 'split-picker-menu';
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
menu.innerHTML = '<div class="split-picker-empty">No other sessions to split with</div>';
|
||||||
|
} else {
|
||||||
|
menu.innerHTML = candidates
|
||||||
|
.map(
|
||||||
|
(c) =>
|
||||||
|
`<div class="split-picker-item" data-session-id="${escapeHtml(c.id)}" onclick="app.openSplitPane(${escapeHtml(JSON.stringify(c.id))}); document.getElementById('splitPickerMenu')?.remove();">${escapeHtml(c.label)}</div>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
const splitBtn = document.querySelector('.btn-split');
|
||||||
|
if (splitBtn) {
|
||||||
|
const rect = splitBtn.getBoundingClientRect();
|
||||||
|
menu.style.position = 'fixed';
|
||||||
|
menu.style.top = `${rect.bottom + 4}px`;
|
||||||
|
menu.style.right = `${window.innerWidth - rect.right}px`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openSplitPane(sessionId) {
|
||||||
|
if (this._splitPane) this.closeSplitPane();
|
||||||
|
|
||||||
|
const wrap = document.querySelector('.terminal-wrap');
|
||||||
|
const parent = wrap.parentElement;
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'terminal-split-container';
|
||||||
|
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'split-divider';
|
||||||
|
|
||||||
|
const paneB = document.createElement('div');
|
||||||
|
paneB.className = 'terminal-pane-b';
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
paneB.innerHTML = `
|
||||||
|
<div class="terminal-pane-b-header">
|
||||||
|
<span>${escapeHtml(session?.name || 'Session')}</span>
|
||||||
|
<span class="terminal-pane-b-close" onclick="app.closeSplitPane()">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-pane-b-container"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
parent.insertBefore(container, wrap);
|
||||||
|
container.appendChild(wrap);
|
||||||
|
wrap.style.flexBasis = '50%';
|
||||||
|
container.appendChild(divider);
|
||||||
|
container.appendChild(paneB);
|
||||||
|
paneB.style.flexBasis = '50%';
|
||||||
|
|
||||||
|
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'));
|
||||||
|
this._splitPane.connect();
|
||||||
|
this._splitSessionId = sessionId;
|
||||||
|
|
||||||
|
this._installSplitDividerDrag(divider, wrap, paneB);
|
||||||
|
},
|
||||||
|
|
||||||
|
closeSplitPane() {
|
||||||
|
if (!this._splitPane) return;
|
||||||
|
this._splitPane.destroy();
|
||||||
|
this._splitPane = null;
|
||||||
|
this._splitSessionId = null;
|
||||||
|
|
||||||
|
const container = document.querySelector('.terminal-split-container');
|
||||||
|
if (!container) return;
|
||||||
|
const wrap = container.querySelector('.terminal-wrap');
|
||||||
|
const parent = container.parentElement;
|
||||||
|
wrap.style.flexBasis = '';
|
||||||
|
parent.insertBefore(wrap, container);
|
||||||
|
container.remove();
|
||||||
|
|
||||||
|
if (this.fitAddon) this.fitAddon.fit();
|
||||||
|
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
_installSplitDividerDrag(divider, wrap, paneB) {
|
||||||
|
let dragging = false;
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const container = divider.parentElement;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const rawPercent = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
|
const percent = window.CodemanSplitPane.clampDividerPercent(rawPercent);
|
||||||
|
wrap.style.flexBasis = `${percent}%`;
|
||||||
|
paneB.style.flexBasis = `${100 - percent}%`;
|
||||||
|
if (this.fitAddon) this.fitAddon.fit();
|
||||||
|
this._splitPane?.fit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
dragging = false;
|
||||||
|
divider.classList.remove('dragging');
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
divider.addEventListener('mousedown', () => {
|
||||||
|
dragging = true;
|
||||||
|
divider.classList.add('dragging');
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/** @fileoverview Real Chromium coverage for split open/close orchestration and the session picker (Task 5). */
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { chromium, type Browser, type Page } from 'playwright';
|
||||||
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
|
||||||
|
const PORT = 3176;
|
||||||
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
|
describe('split-pane orchestration in a real browser', () => {
|
||||||
|
let server: WebServer;
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = new WebServer(PORT, false, true);
|
||||||
|
await server.start();
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||||
|
}, 90000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
if (server) await server.stop();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
async function createShellSession(): Promise<string> {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const res = await fetch('/api/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||||
|
});
|
||||||
|
// POST /api/sessions nests the session under data.session, and mode:'shell'
|
||||||
|
// does not spawn a PTY on creation alone (pid: null, no pane) — an explicit
|
||||||
|
// POST .../shell is what actually starts it (both found and fixed by Task 4's
|
||||||
|
// implementer against this exact pattern; carried forward here so this task
|
||||||
|
// does not rediscover the same two bugs).
|
||||||
|
const id = (await res.json()).data.session.id;
|
||||||
|
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('opening and closing a split reparents and restores .terminal-wrap', async () => {
|
||||||
|
const idA = await createShellSession();
|
||||||
|
const idB = await createShellSession();
|
||||||
|
|
||||||
|
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||||
|
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||||
|
|
||||||
|
expect(await page.evaluate(() => document.querySelector('.terminal-split-container') === null)).toBe(true);
|
||||||
|
|
||||||
|
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||||
|
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
|
||||||
|
|
||||||
|
const duringSplit = await page.evaluate(() => ({
|
||||||
|
hasContainer: document.querySelector('.terminal-split-container') !== null,
|
||||||
|
wrapIsChildOfContainer: document.querySelector('.terminal-split-container > .terminal-wrap') !== null,
|
||||||
|
hasPaneB: document.querySelector('.terminal-pane-b') !== null,
|
||||||
|
}));
|
||||||
|
expect(duringSplit.hasContainer).toBe(true);
|
||||||
|
expect(duringSplit.wrapIsChildOfContainer).toBe(true);
|
||||||
|
expect(duringSplit.hasPaneB).toBe(true);
|
||||||
|
|
||||||
|
await page.evaluate(() => (window as any).app.closeSplitPane());
|
||||||
|
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const afterClose = await page.evaluate(() => ({
|
||||||
|
hasContainer: document.querySelector('.terminal-split-container') === null,
|
||||||
|
wrapRestored: document.querySelector('.main .terminal-wrap') !== null,
|
||||||
|
}));
|
||||||
|
expect(afterClose.hasContainer).toBe(true);
|
||||||
|
expect(afterClose.wrapRestored).toBe(true);
|
||||||
|
|
||||||
|
await page.evaluate(
|
||||||
|
async (ids) => {
|
||||||
|
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
|
||||||
|
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
{ a: idA, b: idB }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the split picker excludes the active session', async () => {
|
||||||
|
const id = await createShellSession();
|
||||||
|
|
||||||
|
await page.evaluate((sid) => (window as any).app.selectSession(sid), id);
|
||||||
|
await page.waitForFunction((sid) => (window as any).app.activeSessionId === sid, id, { timeout: 10000 });
|
||||||
|
|
||||||
|
const pickerExcludesActive = await page.evaluate((sid) => {
|
||||||
|
(window as any).app.openSplitPicker();
|
||||||
|
const items = Array.from(document.querySelectorAll('.split-picker-item'));
|
||||||
|
return !items.some((el) => el.getAttribute('data-session-id') === sid);
|
||||||
|
}, id);
|
||||||
|
expect(pickerExcludesActive).toBe(true);
|
||||||
|
|
||||||
|
await page.evaluate(async (sid) => {
|
||||||
|
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
|
||||||
|
}, id);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user