mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
- A file dragged onto the tile grid navigated the browser away: the single view's drop handler sits on #terminalContainer, hidden while tiles are open. The #tileGrid section now cancels every file dragover and drop (bubble phase, so tab and tile drags stay with _acceptTabDrops), and a drop on a tile uploads its images to THAT tile's session through _uploadAndInsertImages, with the same "Only image files are supported" toast as image-input.js (now in the zh-CN table). - App-driven refocus no longer moves DOM focus into another session's xterm: a remote delete of the focused tile, _reconcileTileGrid and a socket closed with 4003/4004/4010 (_onTileExit) pass focus: false. removeTile gains a focus option; user-initiated removes keep focusing. - Popping out the last tile left the parked terminal's stale content under the popped-out tab (and snapshotted it on the next switch). _selectAfterTileGrid treats a detached session as unusable for both the focused id and the fallback. - "Open group as tiles" and Ctrl/Cmd+click with the grid closed pass mergeSplit: false, so an open split no longer adds its two sessions on top of a set already sized to the group, the count and the window. - Touch-primary devices (primary pointer coarse: iPad, Android tablets) default the Tiles button OFF in getDefaultSettings(); touchscreen laptops (fine primary pointer) keep the desktop default ON. The button, the App Settings chip and the Ctrl+Shift+G gate all resolve an absent key through these defaults, so they agree. CLAUDE.md and the invariants doc say so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
132 lines
5.4 KiB
TypeScript
132 lines
5.4 KiB
TypeScript
/**
|
|
* @fileoverview A file dragged onto the tile grid.
|
|
*
|
|
* The single view's file drop (image-input.js) listens on #terminalContainer,
|
|
* which is hidden while tiles are open. Nothing else cancelled a file drag,
|
|
* so dropping a screenshot on a tile made the browser open the file in place
|
|
* of Codeman. Now the grid section itself takes every file drag (bubble
|
|
* phase): anywhere over it (a tile, an empty cell, a divider, its padding)
|
|
* dragover and drop are cancelled, so the page never navigates, and a drop on
|
|
* a tile uploads its images to THAT tile's session, the same upload and the
|
|
* same "Only image files" toast as the single view. Tab and tile drags are
|
|
* the targets' own (_acceptTabDrops, capture phase) and stay untouched.
|
|
*
|
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { FakeEl, makeGridApp, resetGridHarness, section, tileEl, type GridApp } from './mocks/tile-grid-vm.js';
|
|
|
|
const IDS = ['s-a', 's-b', 's-c'];
|
|
|
|
function fileEvent(target: FakeEl, files: Array<{ type: string; name?: string }>, types = ['Files']) {
|
|
return {
|
|
target,
|
|
dataTransfer: { types, files, dropEffect: 'none' },
|
|
preventDefault: vi.fn(),
|
|
stopPropagation: vi.fn(),
|
|
};
|
|
}
|
|
|
|
/** A grid on `ids`, s-a focused, uploads stubbed. Five tiles make a 3x2 with one empty cell. */
|
|
function gridApp(ids = IDS): GridApp {
|
|
const app = makeGridApp(ids);
|
|
app._uploadAndInsertImages = vi.fn();
|
|
app.openTileGrid(ids, { focusedId: 's-a' });
|
|
return app;
|
|
}
|
|
|
|
const slot = () => section.children.find((el) => el.classList.contains('tile-slot'))!;
|
|
const PNG = { type: 'image/png', name: 'shot.png' };
|
|
|
|
beforeEach(() => {
|
|
resetGridHarness();
|
|
});
|
|
|
|
describe('a file dropped on a tile', () => {
|
|
it("uploads its images to that tile's session (not the focused one), the page staying put", () => {
|
|
const app = gridApp();
|
|
// Deep inside the tile, as on xterm's rows or its helper textarea.
|
|
const inner = tileEl('s-b').querySelector('.tile-body')!;
|
|
const over = fileEvent(inner, [PNG]);
|
|
section.dispatch('dragover', over);
|
|
expect(over.preventDefault).toHaveBeenCalled();
|
|
expect(over.dataTransfer.dropEffect).toBe('copy');
|
|
|
|
const drop = fileEvent(inner, [PNG, { type: 'image/jpeg' }]);
|
|
section.dispatch('drop', drop);
|
|
expect(drop.preventDefault).toHaveBeenCalled();
|
|
expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1);
|
|
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG, { type: 'image/jpeg' }], { sessionId: 's-b' });
|
|
// A drop is not a selection: focus stays where it was.
|
|
expect(app.activeSessionId).toBe('s-a');
|
|
});
|
|
|
|
it('only the images of a mixed drop are uploaded', () => {
|
|
const app = gridApp();
|
|
section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG]));
|
|
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG], { sessionId: 's-c' });
|
|
});
|
|
|
|
it('a drop with no image says so, as the single view does, and uploads nothing', () => {
|
|
const app = gridApp();
|
|
const drop = fileEvent(tileEl('s-b'), [{ type: 'application/pdf' }]);
|
|
section.dispatch('drop', drop);
|
|
expect(drop.preventDefault).toHaveBeenCalled();
|
|
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
|
expect(app.showToast).toHaveBeenCalledWith('Only image files are supported', 'error');
|
|
});
|
|
});
|
|
|
|
describe('a file dragged anywhere else over the grid', () => {
|
|
it('an empty cell, a divider or the padding: cancelled (no navigation), nothing uploaded', () => {
|
|
const app = gridApp([...IDS, 's-d', 's-e']);
|
|
for (const target of [slot(), section]) {
|
|
expect(target).toBeTruthy();
|
|
const over = fileEvent(target, [PNG]);
|
|
section.dispatch('dragover', over);
|
|
expect(over.preventDefault).toHaveBeenCalled();
|
|
const drop = fileEvent(target, [PNG]);
|
|
section.dispatch('drop', drop);
|
|
expect(drop.preventDefault).toHaveBeenCalled();
|
|
}
|
|
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
|
expect(app.showToast).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('tab and tile drags are left to their targets', () => {
|
|
it('a drag that carries no files is not touched by the grid section', () => {
|
|
const app = gridApp();
|
|
app.draggedTabId = 's-other';
|
|
const over = fileEvent(section, [], ['text/plain']);
|
|
section.dispatch('dragover', over);
|
|
section.dispatch('drop', over);
|
|
expect(over.preventDefault).not.toHaveBeenCalled();
|
|
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a tab dropped on a tile still replaces it through the tile's own handler", () => {
|
|
const app = gridApp();
|
|
app.draggedTabId = 's-other';
|
|
const drop = fileEvent(tileEl('s-b'), [], ['text/plain']);
|
|
tileEl('s-b').dispatch('drop', drop);
|
|
expect(app._tileGrid.ids).toContain('s-other');
|
|
expect(app._tileGrid.ids).not.toContain('s-b');
|
|
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('the guard is installed once', () => {
|
|
it('opening and closing the grid again never stacks listeners', () => {
|
|
const app = gridApp();
|
|
app.closeTileGrid({ reselect: false });
|
|
app.openTileGrid(IDS);
|
|
app.closeTileGrid({ reselect: false });
|
|
app.openTileGrid(IDS);
|
|
expect(section.listeners.dragover).toHaveLength(1);
|
|
expect(section.listeners.drop).toHaveLength(1);
|
|
// Bubble phase: the tab and tile drop targets stop their own drags in capture.
|
|
expect(section.captureFlags.drop).toEqual([false]);
|
|
});
|
|
});
|