Tear-off and re-docking fixed

This commit is contained in:
2026-05-07 12:08:08 -04:00
parent 75ecdd346d
commit d8e5b15ac3
51 changed files with 4664 additions and 205 deletions
@@ -0,0 +1,171 @@
use std::collections::HashMap;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, LogicalPosition, Manager, WebviewWindowBuilder};
/// Module-level state: pending initialization payloads keyed by window label.
/// Populated by `spawn_child_window` before the window is built; consumed once
/// by `get_window_payload` when the child's React app mounts.
pub struct WindowPayloads(pub Mutex<HashMap<String, String>>);
/// Return the labels of all currently open webview windows.
#[tauri::command]
pub fn get_window_labels(app: AppHandle) -> Vec<String> {
app.webview_windows().keys().cloned().collect()
}
/// Send a serialized tab to an already-open window by label.
#[tauri::command]
pub fn send_tab_to_window(app: AppHandle, label: String, payload: String) -> Result<(), String> {
let win = app
.get_webview_window(&label)
.ok_or_else(|| format!("window '{}' not found", label))?;
win.emit("tab:hydrate", &payload).map_err(|e| e.to_string())
}
/// Called by the child window's React app on mount to pull its initialization
/// payload (serialized tab JSON + workspaceRoot). Returns `None` if no payload
/// is pending (e.g. in browser/dev mode). Atomically removes the entry so
/// multiple calls return the data only once.
#[tauri::command]
pub fn get_window_payload(
window: tauri::WebviewWindow,
payloads: tauri::State<WindowPayloads>,
) -> Option<String> {
payloads.0.lock().unwrap().remove(window.label())
}
/// Spawn a new child window with the given serialized tab payload.
///
/// The payload is stored in `WindowPayloads` state BEFORE the window is
/// created. When the child's React app mounts it calls `get_window_payload`
/// (a request/response invoke) to pull the data — no race condition, no
/// event-timing dependency.
///
/// Optional `x`/`y` are logical screen coordinates for the window's top-left
/// corner. When provided the window is repositioned immediately after build
/// so it appears near the cursor drop point.
#[tauri::command]
pub async fn spawn_child_window(
app: AppHandle,
label: String,
payload: String,
x: Option<f64>,
y: Option<f64>,
) -> Result<(), String> {
// Store payload first so it's ready the moment the window loads.
app.state::<WindowPayloads>()
.0
.lock()
.unwrap()
.insert(label.clone(), payload);
let win = WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::App("/?role=child".into()))
.title("ByteDraft")
.inner_size(1024.0, 720.0)
.min_inner_size(600.0, 400.0)
.resizable(true)
.decorations(true)
.transparent(true)
.hidden_title(true)
.title_bar_style(tauri::TitleBarStyle::Overlay)
.build()
.map_err(|e| {
// Roll back the stored payload if window creation fails.
app.state::<WindowPayloads>()
.0
.lock()
.unwrap()
.remove(&label);
e.to_string()
})?;
if let (Some(wx), Some(wy)) = (x, y) {
win.set_position(LogicalPosition::new(wx, wy)).ok();
}
// Focus the window so drag regions and native controls initialize correctly.
win.set_focus().ok();
// WindowBackground is the correct vibrancy material for full editor windows.
// Sidebar is designed for narrow sidebar panels and renders as a strongly
// translucent blue-grey on a window with no sidebar content. WindowBackground
// gives a more opaque, neutral macOS window appearance.
#[cfg(target_os = "macos")]
{
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
if let Err(e) = apply_vibrancy(&win, NSVisualEffectMaterial::WindowBackground, None, None) {
eprintln!("[spawn_child_window] vibrancy failed: {e}");
}
}
// Suppress unused-variable warning on non-macOS platforms.
#[cfg(not(target_os = "macos"))]
let _ = &win;
Ok(())
}
/// Spawn a hidden child window and load the React app so it is ready to be
/// activated instantly when a tear-off occurs. No payload is stored; the
/// window waits for a `tab:hydrate` event (or a subsequent `set_window_payload`
/// + poll) once the user actually tears off a tab.
#[tauri::command]
pub async fn prewarm_child_window(app: AppHandle, label: String) -> Result<(), String> {
let win = WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::App("/?role=child".into()))
.title("ByteDraft")
.inner_size(1024.0, 720.0)
.min_inner_size(600.0, 400.0)
.resizable(true)
.decorations(true)
.transparent(true)
.hidden_title(true)
.title_bar_style(tauri::TitleBarStyle::Overlay)
.visible(false)
.build()
.map_err(|e| e.to_string())?;
#[cfg(target_os = "macos")]
{
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
if let Err(e) = apply_vibrancy(&win, NSVisualEffectMaterial::WindowBackground, None, None) {
eprintln!("[prewarm_child_window] vibrancy failed: {e}");
}
}
#[cfg(not(target_os = "macos"))]
let _ = &win;
Ok(())
}
/// Store a serialized tab payload for the given window label so the window can
/// retrieve it via `get_window_payload` if the `tab:hydrate` event was missed
/// (race condition during pre-warm activation).
#[tauri::command]
pub fn set_window_payload(
payloads: tauri::State<WindowPayloads>,
label: String,
payload: String,
) {
payloads.0.lock().unwrap().insert(label, payload);
}
/// Position, show, and focus a pre-warmed hidden window at the given logical
/// screen coordinates. `x`/`y` should be the cursor's screen position so the
/// window appears near where the user dropped the tab.
#[tauri::command]
pub fn activate_prewarm_window(
app: AppHandle,
label: String,
x: f64,
y: f64,
) -> Result<(), String> {
let win = app
.get_webview_window(&label)
.ok_or_else(|| format!("window '{}' not found", label))?;
win.set_position(LogicalPosition::new(x, y))
.map_err(|e| e.to_string())?;
win.show().map_err(|e| e.to_string())?;
win.set_focus().map_err(|e| e.to_string())?;
Ok(())
}
+252 -36
View File
@@ -36,8 +36,72 @@ Object.defineProperty(exports, "__esModule", { value: true });
const electron_1 = require("electron");
const path = __importStar(require("path"));
const bridge_1 = require("./bridge");
const uiohook_napi_1 = require("uiohook-napi");
// Use PNG at runtime — nativeImage loads it more reliably than .icns in dev mode.
// The .icns file is used only by the electron-forge packager (forge.config.js).
const ICON_PATH = process.platform === 'win32'
? path.join(__dirname, '../electron/assets/icons/icon.ico')
: path.join(__dirname, '../electron/assets/icons/icon.png');
const appIcon = electron_1.nativeImage.createFromPath(ICON_PATH);
if (appIcon.isEmpty())
console.error('[icon] failed to load from:', ICON_PATH);
const isDev = process.argv.includes('--dev');
const DEV_URL = 'http://localhost:5173';
// ── Window registry ──────────────────────────────────────────────────────────
// Tracks every open BrowserWindow with its label and role.
const windowRegistry = new Map();
// Payloads waiting to be picked up by newly spawned child windows.
const windowPayloads = new Map();
// ── Cross-window drag state ───────────────────────────────────────────────────
// HTML5 DnD dataTransfer.getData() is empty across renderer processes.
// We store the drag payload here so any window can retrieve it on drop.
let _activeDrag = null;
let _dragWasAccepted = false;
let _dragPollingInterval = null;
function registerWindow(win, label, isMainWindow) {
const id = win.webContents.id;
windowRegistry.set(id, { label, isMainWindow, win });
win.on('closed', () => windowRegistry.delete(id));
}
function createChildWindow(label, extraOpts = {}, tornOff = false) {
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const opts = {
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
frame: !isWindows,
backgroundColor: '#111111',
icon: appIcon,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
...extraOpts,
};
if (isMac) {
opts.titleBarStyle = 'hiddenInset';
opts.trafficLightPosition = { x: 16, y: 12 };
// vibrancy omitted for child windows — Electron 42 crashes in its Rust BMP compositor
// when a right-click sendEvent triggers image processing on a vibrancy-backed child window.
}
const win = new electron_1.BrowserWindow(opts);
registerWindow(win, label, false);
if (isDev) {
win.loadURL(tornOff ? `${DEV_URL}?tornOff=1` : DEV_URL).catch(console.error);
}
else {
void win.loadFile(path.join(__dirname, '../ui/dist/index.html'), tornOff ? { query: { tornOff: '1' } } : {});
}
win.webContents.on('will-navigate', (e, url) => {
if (url !== win.webContents.getURL())
e.preventDefault();
});
return win;
}
function createWindow() {
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
@@ -48,6 +112,7 @@ function createWindow() {
minHeight: 600,
frame: !isWindows,
backgroundColor: '#111111',
icon: appIcon,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
@@ -61,6 +126,7 @@ function createWindow() {
opts.vibrancy = 'hud';
}
const win = new electron_1.BrowserWindow(opts);
registerWindow(win, 'main', true);
if (isDev) {
win.loadURL(DEV_URL).catch(console.error);
win.webContents.openDevTools();
@@ -68,7 +134,6 @@ function createWindow() {
else {
void win.loadFile(path.join(__dirname, '../ui/dist/index.html'));
}
// Prevent external navigation from file drop
win.webContents.on('will-navigate', (e, url) => {
if (url !== win.webContents.getURL())
e.preventDefault();
@@ -76,57 +141,61 @@ function createWindow() {
return win;
}
electron_1.app.whenReady().then(() => {
if (process.platform === 'darwin' && !appIcon.isEmpty()) {
electron_1.app.dock?.setIcon(appIcon);
}
(0, bridge_1.startBridge)();
createWindow();
electron_1.app.on('activate', () => {
if (electron_1.BrowserWindow.getAllWindows().length === 0)
createWindow();
});
// Global mouse hook — detects the actual mouse release for cross-window tab drops.
// HTML5 DnD events are sandboxed per renderer on macOS; this is the only reliable way
// to know the user released the mouse over a different BrowserWindow.
uiohook_napi_1.uIOhook.on('mouseup', (e) => {
if (e.button !== 1)
return; // 1 = left button in uiohook-napi
if (!_activeDrag)
return;
if (_dragPollingInterval) {
clearInterval(_dragPollingInterval);
_dragPollingInterval = null;
}
const { x, y } = electron_1.screen.getCursorScreenPoint();
const targetWin = windowAtPoint(x, y);
const sourceEntry = Array.from(windowRegistry.values()).find(v => v.label === _activeDrag.sourceWindow);
if (targetWin && sourceEntry && targetWin.webContents.id !== sourceEntry.win.webContents.id) {
const drag = _activeDrag;
_dragWasAccepted = true;
_activeDrag = null;
targetWin.webContents.send('tab:cross-drop', { payload: drag.payload, sourceWindow: drag.sourceWindow });
}
else {
// Released on source window or outside any window — broadcast cleanup.
_activeDrag = null;
for (const { win } of windowRegistry.values()) {
win.webContents.send('tab:drag-ended', {});
}
}
});
uiohook_napi_1.uIOhook.start();
});
electron_1.app.on('window-all-closed', () => {
if (process.platform !== 'darwin')
electron_1.app.quit();
electron_1.app.quit();
});
electron_1.app.on('before-quit', () => {
uiohook_napi_1.uIOhook.stop();
(0, bridge_1.stopBridge)();
});
// ── Tear-off windows ─────────────────────────────────────────────────────────
// ── Legacy tear-off (window.api path) ────────────────────────────────────────
// Kept for compatibility with TitleBar's api.tearOffTab() call.
electron_1.ipcMain.on('tearOffTab', (_e, tabJson) => {
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const opts = {
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
frame: !isWindows,
backgroundColor: '#111111',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
};
if (isMac) {
opts.titleBarStyle = 'hiddenInset';
opts.trafficLightPosition = { x: 16, y: 12 };
opts.vibrancy = 'hud';
}
const win = new electron_1.BrowserWindow(opts);
if (isDev) {
win.loadURL(`${DEV_URL}?tornOff=1`).catch(console.error);
}
else {
void win.loadFile(path.join(__dirname, '../ui/dist/index.html'), { query: { tornOff: '1' } });
}
const label = `win-torn-${crypto.randomUUID().slice(0, 8)}`;
const win = createChildWindow(label, {}, true);
win.webContents.once('did-finish-load', () => {
win.webContents.send('restoreTornTab', tabJson);
});
win.webContents.on('will-navigate', (e, url) => {
if (url !== win.webContents.getURL())
e.preventDefault();
});
});
// ── Window controls ───────────────────────────────────────────────────────────
electron_1.ipcMain.on('windowMinimize', (e) => {
@@ -141,6 +210,153 @@ electron_1.ipcMain.on('windowToggleMaximize', (e) => {
electron_1.ipcMain.on('windowClose', (e) => {
electron_1.BrowserWindow.fromWebContents(e.sender)?.close();
});
// ── window.byteDraft bridge support ──────────────────────────────────────────
// Sync call from preload: renderer needs its label + isMainWindow at startup.
electron_1.ipcMain.on('get_window_info', (event) => {
const info = windowRegistry.get(event.sender.id);
event.returnValue = info
? { label: info.label, isMainWindow: info.isMainWindow }
: { label: 'unknown', isMainWindow: false };
});
// Cross-window event routing: forward payload to all windows except the sender.
electron_1.ipcMain.handle('__emit__', (event, channel, payload) => {
for (const [id, { win }] of windowRegistry) {
if (id !== event.sender.id) {
win.webContents.send(channel, payload);
}
}
});
electron_1.ipcMain.handle('get_window_labels', () => Array.from(windowRegistry.values()).map((v) => v.label));
// Child windows call this on mount to retrieve their tab payload (consumed once).
electron_1.ipcMain.handle('get_window_payload', (event) => {
const info = windowRegistry.get(event.sender.id);
if (!info)
return null;
const payload = windowPayloads.get(info.label) ?? null;
windowPayloads.delete(info.label);
return payload;
});
electron_1.ipcMain.handle('set_window_payload', (_e, args) => {
windowPayloads.set(args.label, args.payload);
});
// HTML5 DnD cross-window payload relay.
// dataTransfer.getData() is empty across renderer processes; these handlers let any
// window retrieve the drag payload that was set by the source window's dragstart.
electron_1.ipcMain.handle('set_drag_payload', (_e, args) => {
let tabId = null;
try {
tabId = JSON.parse(args.payload).id ?? null;
}
catch { /* ignore */ }
_activeDrag = { payload: args.payload, sourceWindow: args.sourceWindow, tabId };
_dragWasAccepted = false;
});
electron_1.ipcMain.handle('get_drag_payload', () => {
const drag = _activeDrag;
if (drag !== null) {
_dragWasAccepted = true;
_activeDrag = null;
}
return drag;
});
electron_1.ipcMain.handle('was_drag_accepted', () => {
const result = _dragWasAccepted;
_dragWasAccepted = false;
return result;
});
// Main-process mouse tracking for cross-renderer tab drag-and-drop.
// HTML5 dragover/drop events are sandboxed per renderer process and never fire in
// a different BrowserWindow's DOM. Instead, we poll the cursor position here and
// route synthetic drag events to whichever window is under the cursor.
function windowAtPoint(x, y) {
for (const { win } of windowRegistry.values()) {
if (win.isDestroyed())
continue;
const b = win.getBounds();
if (x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height)
return win;
}
return null;
}
electron_1.ipcMain.handle('start_drag_tracking', (event) => {
if (_dragPollingInterval)
clearInterval(_dragPollingInterval);
const sourceId = event.sender.id;
_dragPollingInterval = setInterval(() => {
if (!_activeDrag) {
clearInterval(_dragPollingInterval);
_dragPollingInterval = null;
return;
}
const { x, y } = electron_1.screen.getCursorScreenPoint();
const targetWin = windowAtPoint(x, y);
if (!targetWin || targetWin.webContents.id === sourceId)
return;
const bounds = targetWin.getBounds();
targetWin.webContents.send('tab:cross-drag-over', { clientX: x - bounds.x, clientY: y - bounds.y });
}, 16);
});
electron_1.ipcMain.handle('finalize_cross_drag', (event) => {
if (_dragPollingInterval) {
clearInterval(_dragPollingInterval);
_dragPollingInterval = null;
}
const { x, y } = electron_1.screen.getCursorScreenPoint();
const targetWin = windowAtPoint(x, y);
if (targetWin && targetWin.webContents.id !== event.sender.id && _activeDrag) {
const drag = _activeDrag;
_dragWasAccepted = true;
_activeDrag = null;
targetWin.webContents.send('tab:cross-drop', { payload: drag.payload, sourceWindow: drag.sourceWindow });
}
});
// Target renderer calls this after accepting a cross-renderer drop; we notify the source to remove its copy.
electron_1.ipcMain.handle('notify_drop_accepted', (_e, args) => {
const tabId = args.tabId ?? _activeDrag?.tabId ?? null;
_dragWasAccepted = true;
_activeDrag = null;
for (const { label, win } of windowRegistry.values()) {
if (label === args.sourceWindow) {
win.webContents.send('tab:drop-accepted', { tabId });
return;
}
}
});
// Route tab:hydrate to a specific window by label.
electron_1.ipcMain.handle('send_tab_to_window', (_e, args) => {
for (const { label, win } of windowRegistry.values()) {
if (label === args.label) {
win.webContents.send('tab:hydrate', args.payload);
return;
}
}
});
// Spawn a new visible child window, storing its payload for pull-model init.
electron_1.ipcMain.handle('spawn_child_window', (_e, args) => {
windowPayloads.set(args.label, args.payload);
const win = createChildWindow(args.label);
if (args.x !== undefined && args.y !== undefined)
win.setPosition(args.x, args.y);
});
// Spawn a hidden child window for pre-warming.
electron_1.ipcMain.handle('prewarm_child_window', (_e, args) => {
createChildWindow(args.label, { show: false });
});
// Show and position a previously pre-warmed hidden window.
electron_1.ipcMain.handle('activate_prewarm_window', (_e, args) => {
for (const { label, win } of windowRegistry.values()) {
if (label === args.label) {
if (args.x !== undefined && args.y !== undefined)
win.setPosition(args.x, args.y);
win.show();
win.focus();
return;
}
}
});
// Snake-case session commands used by window.byteDraft bridge
electron_1.ipcMain.handle('load_session', () => (0, bridge_1.callBridge)('load_session'));
electron_1.ipcMain.handle('save_session', (_e, args) => (0, bridge_1.callBridge)('save_session', { session: args.session }));
// ── Native dialogs (handled in Electron, not the Rust subprocess) ─────────────
electron_1.ipcMain.handle('showMessageBox', async (e, opts) => {
const win = electron_1.BrowserWindow.fromWebContents(e.sender);
+17
View File
@@ -1,6 +1,23 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const electron_1 = require("electron");
// Retrieve window identity synchronously so it is available before React mounts.
const windowInfo = electron_1.ipcRenderer.sendSync('get_window_info');
// ── window.byteDraft — the cross-window bridge ────────────────────────────────
electron_1.contextBridge.exposeInMainWorld('byteDraft', {
windowLabel: windowInfo.label,
isMainWindow: windowInfo.isMainWindow,
invoke: (cmd, args) => electron_1.ipcRenderer.invoke(cmd, args),
emit: (channel, payload) => electron_1.ipcRenderer.invoke('__emit__', channel, payload),
subscribe: (channel, handler) => {
const listener = (_e, payload) => handler(payload);
electron_1.ipcRenderer.on(channel, listener);
return () => electron_1.ipcRenderer.removeListener(channel, listener);
},
minimize: () => electron_1.ipcRenderer.send('windowMinimize'),
toggleMaximize: () => electron_1.ipcRenderer.send('windowToggleMaximize'),
closeWindow: () => electron_1.ipcRenderer.send('windowClose'),
});
electron_1.contextBridge.exposeInMainWorld('api', {
// ── File operations ─────────────────────────────────────────────────────────
openFile: (filePath) => electron_1.ipcRenderer.invoke('openFile', filePath),
+21
View File
@@ -0,0 +1,21 @@
/**
* Verifies the app dock icon is set to a non-empty custom icon.
* Catches regression where nativeImage.createFromPath fails silently and
* app.dock.setIcon() is never called, leaving the default Electron icon.
*/
import { test, expect } from './setup';
test('dock icon is a non-empty custom image', async ({ electronApp }) => {
// Give the app a moment to call app.dock.setIcon()
await new Promise((r) => setTimeout(r, 500));
const iconIsSet = await electronApp.evaluate(({ app }) => {
// app.dock is macOS only; on other platforms this will be undefined
const dock = (app as Electron.App & { dock?: Electron.Dock }).dock;
if (!dock) return true; // non-macOS: icon set via BrowserWindow, skip
const icon = dock.getIcon();
return icon !== null && !icon.isEmpty();
});
expect(iconIsSet).toBe(true);
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Verifies the tab re-dock flow: tear a tab off to a child window, then
* move it back to the main window via the context menu.
*
* LIMITATION: True cross-window drag (physically dragging the ghost across
* window bounds) is not simulatable with Playwright's current APIs — it
* requires OS-level mouse event injection across process boundaries.
* We test the semantically equivalent "Move to Main Window" context menu,
* which invokes the same IPC pipeline as drag-to-main.
*
* When Playwright adds cross-window drag support, replace the context menu
* interaction with:
* await childPage.mouse.down();
* await mainPage.mouse.move(x, y);
* await mainPage.mouse.up();
*/
import { test, expect } from './setup';
test('context menu Move to Main Window returns tab to main window', async ({ electronApp, mainPage }) => {
// Open a second tab
await mainPage.keyboard.press('Meta+n');
await mainPage.waitForTimeout(200);
const tabsBefore = await mainPage.locator('[data-tab-id]').count();
expect(tabsBefore).toBeGreaterThanOrEqual(2);
// Get the second tab's ID
const tabToMove = mainPage.locator('[data-tab-id]').nth(1);
const tabId = await tabToMove.getAttribute('data-tab-id');
expect(tabId).toBeTruthy();
// Tear off the second tab via dnd-kit (simulate a large downward drag)
const tabBox = await tabToMove.boundingBox();
if (!tabBox) throw new Error('Tab has no bounding box');
await mainPage.mouse.move(tabBox.x + tabBox.width / 2, tabBox.y + tabBox.height / 2);
await mainPage.mouse.down();
await mainPage.mouse.move(tabBox.x + tabBox.width / 2, tabBox.y + 200, { steps: 20 });
await mainPage.mouse.up();
await mainPage.waitForTimeout(1000); // allow child window to spawn
// Verify main window lost the tab
const mainTabsAfterTearOff = await mainPage.locator('[data-tab-id]').count();
expect(mainTabsAfterTearOff).toBe(tabsBefore - 1);
// Find the child window
const windows = electronApp.windows();
const childPage = windows.find((w) => w !== mainPage);
if (!childPage) {
// Tear-off may not have spawned yet; wait a bit more
await mainPage.waitForTimeout(1500);
const windowsRetry = electronApp.windows();
const child = windowsRetry.find((w) => w !== mainPage);
if (!child) throw new Error('Child window did not spawn after tear-off');
await doRedock(child, mainPage, tabsBefore);
} else {
await doRedock(childPage, mainPage, tabsBefore);
}
});
async function doRedock(childPage: import('playwright').Page, mainPage: import('playwright').Page, expectedCount: number) {
await childPage.waitForLoadState('domcontentloaded');
// Right-click the tab in the child window to open context menu
const childTab = childPage.locator('[data-tab-id]').first();
await childTab.click({ button: 'right' });
await childPage.waitForTimeout(200);
// Click "Move to Main Window"
const moveItem = childPage.locator('[role="menuitem"]', { hasText: 'Move to Main Window' });
await expect(moveItem).toBeVisible();
await moveItem.click();
await mainPage.waitForTimeout(500);
// Main window should now have the original count restored
const mainTabsAfterRedock = await mainPage.locator('[data-tab-id]').count();
expect(mainTabsAfterRedock).toBe(expectedCount);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Shared Electron launch fixture for all e2e/electron tests.
*
* Usage:
* import { test, expect, launchApp } from './setup';
*
* The `electronApp` fixture gives you the ElectronApplication, and
* `mainPage` gives you the main window's Page object.
*
* TESTING LIMITATIONS (document here so future contributors know):
*
* What Playwright + real Electron CAN test:
* - DOM rendering, clicks, keyboard input within a window
* - Horizontal scroll (page.mouse.wheel)
* - Tab drag within a window (locator.dragTo)
* - Cross-window IPC (via electronApp.evaluate)
* - Dock / app-level APIs (via electronApp.evaluate)
*
* What still requires manual verification:
* - Native OS drag image appearance (the "floating ghost")
* - -webkit-app-region drag behaviour (OS-level, not DOM)
* - Cross-window HTML5 DnD drop (Playwright mouse primitives work within a
* single window; triggering a real cross-window mouseup+drop sequence
* requires Chromium-internal coordination that Playwright doesn't expose)
*
* For cross-window re-dock, use the context menu "Move to Main Window" as the
* testable proxy until Playwright adds cross-window drag support.
*/
import { test as base, expect } from '@playwright/test';
import { _electron as electron } from 'playwright';
import type { ElectronApplication, Page } from 'playwright';
import path from 'path';
interface AppFixtures {
electronApp: ElectronApplication;
mainPage: Page;
}
export const test = base.extend<AppFixtures>({
electronApp: async ({}, use) => {
const app = await electron.launch({
args: [path.join(__dirname, '../../dist-electron/main.js'), '--dev'],
});
await use(app);
await app.close();
},
mainPage: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow();
await page.waitForLoadState('domcontentloaded');
await use(page);
},
});
export { expect };
+51
View File
@@ -0,0 +1,51 @@
/**
* Verifies within-window tab drag (reorder) and the absence of unwanted
* vertical page scroll during a horizontal drag.
*
* NOTE: dnd-kit's pointer-event drag and HTML5 DnD (our cross-window layer)
* both activate during a drag. Within a single window, drop lands on the
* same tab strip, triggering the HTML5 reorder path (reorderTabs).
*/
import { test, expect } from './setup';
test('dragging a tab within the strip reorders tabs', async ({ mainPage }) => {
// Open a second tab so there are two to reorder
await mainPage.keyboard.press('Meta+n');
await mainPage.waitForTimeout(200);
const tabs = mainPage.locator('[data-tab-id]');
const count = await tabs.count();
expect(count).toBeGreaterThanOrEqual(2);
const first = tabs.nth(0);
const second = tabs.nth(1);
const firstIdBefore = await first.getAttribute('data-tab-id');
const secondIdBefore = await second.getAttribute('data-tab-id');
// Drag first tab to second position
await first.dragTo(second);
await mainPage.waitForTimeout(200);
// After drag, positions should be swapped
const firstIdAfter = await tabs.nth(0).getAttribute('data-tab-id');
expect(firstIdAfter).not.toBe(firstIdBefore);
expect(firstIdAfter).toBe(secondIdBefore);
});
test('no vertical page scroll occurs during tab drag', async ({ mainPage }) => {
await mainPage.keyboard.press('Meta+n');
await mainPage.waitForTimeout(200);
const tabs = mainPage.locator('[data-tab-id]');
const first = tabs.nth(0);
const second = tabs.nth(1);
const bodyScrollBefore = await mainPage.evaluate(() => document.documentElement.scrollTop);
await first.dragTo(second);
await mainPage.waitForTimeout(200);
const bodyScrollAfter = await mainPage.evaluate(() => document.documentElement.scrollTop);
expect(bodyScrollAfter).toBe(bodyScrollBefore);
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Verifies horizontal tab strip scroll via mouse wheel / trackpad swipe.
*
* The scroll container has -webkit-app-region: no-drag but Electron can still
* absorb wheel events at the OS level. Our fix adds an explicit onWheel handler
* that calls scrollBy() directly. This test confirms that handler fires.
*
* Approach: add enough tabs to overflow the container, hover the scroll region,
* dispatch a wheel event, then assert scrollLeft advanced.
*/
import { test, expect } from './setup';
async function addTabs(page: import('playwright').Page, count: number) {
// Open tabs via keyboard shortcut or menu — adjust to whatever ByteDraft exposes
for (let i = 0; i < count; i++) {
await page.keyboard.press('Meta+n');
await page.waitForTimeout(100);
}
}
test('tab strip scrolls horizontally with mouse wheel', async ({ mainPage }) => {
// Add enough tabs to overflow the strip
await addTabs(mainPage, 12);
const scrollRegion = mainPage.locator('[data-testid="tab-scroll-region"]');
await scrollRegion.hover();
const beforeScroll = await scrollRegion.evaluate((el) => el.scrollLeft);
// Simulate rightward trackpad swipe (deltaX) or mouse wheel (map deltaY → X)
await mainPage.mouse.wheel(100, 0);
await mainPage.waitForTimeout(50);
const afterScroll = await scrollRegion.evaluate((el) => el.scrollLeft);
expect(afterScroll).toBeGreaterThan(beforeScroll);
});
test('tab strip does not scroll vertically when wheel fires', async ({ mainPage }) => {
await addTabs(mainPage, 8);
const scrollRegion = mainPage.locator('[data-testid="tab-scroll-region"]');
await scrollRegion.hover();
const beforeScrollY = await scrollRegion.evaluate((el) => el.scrollTop);
await mainPage.mouse.wheel(0, 100);
await mainPage.waitForTimeout(50);
const afterScrollY = await scrollRegion.evaluate((el) => el.scrollTop);
// overflow-y-hidden — vertical scroll should stay at 0
expect(afterScrollY).toBe(beforeScrollY);
});
+146
View File
@@ -0,0 +1,146 @@
/**
* Multi-window E2E tests: tab tear-off via context menu, keyboard shortcut,
* and the "Move to Window" redock submenu.
*
* All Tauri IPC calls are intercepted by the browser-mode mock in
* fixtures/tauri-test.ts — no Tauri binary is required.
*
* Tear-off flows:
* spawn_child_window → succeeds (mocked) → removeTabSilently → tab gone
* send_tab_to_window → succeeds (mocked) → removeTabSilently → tab gone
* get_window_labels → returns ['main', 'win-e2e-test'] (mocked)
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { test, expect } from '../fixtures/tauri-test';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES = path.resolve(__dirname, '../fixtures');
type Win = Window & {
__openFileByPath?: (p: string) => Promise<void>;
};
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ tauriPage }) => {
await tauriPage.playwrightPage.waitForFunction(
() => {
const w = window as Win;
return w.__openFileByPath != null;
},
{ timeout: 15_000 },
);
await tauriPage.playwrightPage.waitForTimeout(400);
// Ensure there is at least one non-blank tab to work with.
await tauriPage.playwrightPage.evaluate(async (fixturesDir) => {
await (window as Win).__openFileByPath!(`${fixturesDir}/sample.txt`);
}, FIXTURES);
});
// ── Context menu structure ─────────────────────────────────────────────────
test.describe('Tab context menu', () => {
test('right-clicking a tab renders the context menu', async ({ tauriPage }) => {
const lastTab = tauriPage.locator('[data-testid^="tab-btn-"]').last();
await expect(lastTab).toBeVisible({ timeout: 5_000 });
await lastTab.click({ button: 'right' });
await expect(
tauriPage.locator('[data-testid="tab-context-menu"]'),
).toBeVisible({ timeout: 2_000 });
});
test('context menu contains "Move to New Window"', async ({ tauriPage }) => {
const lastTab = tauriPage.locator('[data-testid^="tab-btn-"]').last();
await lastTab.click({ button: 'right' });
await expect(
tauriPage
.locator('[data-testid="tab-context-menu"]')
.getByRole('menuitem', { name: 'Move to New Window' }),
).toBeVisible({ timeout: 2_000 });
});
});
// ── Tear-off via context menu ──────────────────────────────────────────────
test.describe('Tear-off — context menu', () => {
test('"Move to New Window" removes the tab from the current window', async ({ tauriPage }) => {
const tabs = tauriPage.locator('[data-testid^="tab-btn-"]');
const countBefore = await tabs.count();
const lastTab = tabs.last();
await lastTab.click({ button: 'right' });
await tauriPage
.locator('[data-testid="tab-context-menu"]')
.getByRole('menuitem', { name: 'Move to New Window' })
.click();
await expect.poll(() => tabs.count(), { timeout: 3_000 }).toBe(countBefore - 1);
});
});
// ── Tear-off via keyboard shortcut ────────────────────────────────────────
test.describe('Tear-off — keyboard shortcut', () => {
test('Cmd+Shift+N tears off the active tab', async ({ tauriPage }) => {
const pw = tauriPage.playwrightPage;
const tabs = tauriPage.locator('[data-testid^="tab-btn-"]');
// Activate the last tab via real click (same path users take).
const lastTab = tabs.last();
await lastTab.click();
const countBefore = await tabs.count();
expect(countBefore).toBeGreaterThan(0);
await pw.keyboard.press('Meta+Shift+N');
await expect.poll(() => tabs.count(), { timeout: 3_000 }).toBe(countBefore - 1);
});
});
// ── Move to Window submenu (redock) ────────────────────────────────────────
test.describe('Move to Window submenu', () => {
test('submenu lists other open windows', async ({ tauriPage }) => {
// The ipcMock returns ['main', 'win-e2e-test']; browser-mode falls back to
// filtering out 'main', so 'win-e2e-test' appears as the only other window.
const lastTab = tauriPage.locator('[data-testid^="tab-btn-"]').last();
await lastTab.click({ button: 'right' });
const menu = tauriPage.locator('[data-testid="tab-context-menu"]');
// "Move to Window" button only appears when otherWindows is non-empty.
await expect(menu.getByText('Move to Window')).toBeVisible({ timeout: 3_000 });
// Hover to reveal submenu.
await menu.getByText('Move to Window').hover();
await expect(tauriPage.getByRole('menuitem', { name: 'win-e2e-test' })).toBeVisible({
timeout: 2_000,
});
});
test('"Move to Window" sends the tab to the target and removes it locally', async ({
tauriPage,
}) => {
const tabs = tauriPage.locator('[data-testid^="tab-btn-"]');
const countBefore = await tabs.count();
const lastTab = tabs.last();
await lastTab.click({ button: 'right' });
const menu = tauriPage.locator('[data-testid="tab-context-menu"]');
await expect(menu.getByText('Move to Window')).toBeVisible({ timeout: 3_000 });
await menu.getByText('Move to Window').hover();
await tauriPage.getByRole('menuitem', { name: 'win-e2e-test' }).click();
await expect.poll(() => tabs.count(), { timeout: 3_000 }).toBe(countBefore - 1);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+258 -41
View File
@@ -1,10 +1,79 @@
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import { app, BrowserWindow, ipcMain, dialog, nativeImage, screen } from 'electron';
import * as path from 'path';
import { startBridge, callBridge, stopBridge } from './bridge';
import { uIOhook } from 'uiohook-napi';
// Use PNG at runtime — nativeImage loads it more reliably than .icns in dev mode.
// The .icns file is used only by the electron-forge packager (forge.config.js).
const ICON_PATH = process.platform === 'win32'
? path.join(__dirname, '../electron/assets/icons/icon.ico')
: path.join(__dirname, '../electron/assets/icons/icon.png');
const appIcon = nativeImage.createFromPath(ICON_PATH);
if (appIcon.isEmpty()) console.error('[icon] failed to load from:', ICON_PATH);
const isDev = process.argv.includes('--dev');
const DEV_URL = 'http://localhost:5173';
// ── Window registry ──────────────────────────────────────────────────────────
// Tracks every open BrowserWindow with its label and role.
const windowRegistry = new Map<number, { label: string; isMainWindow: boolean; win: BrowserWindow }>();
// Payloads waiting to be picked up by newly spawned child windows.
const windowPayloads = new Map<string, string>();
// ── Cross-window drag state ───────────────────────────────────────────────────
// HTML5 DnD dataTransfer.getData() is empty across renderer processes.
// We store the drag payload here so any window can retrieve it on drop.
let _activeDrag: { payload: string; sourceWindow: string; tabId: string | null } | null = null;
let _dragWasAccepted = false;
let _dragPollingInterval: ReturnType<typeof setInterval> | null = null;
function registerWindow(win: BrowserWindow, label: string, isMainWindow: boolean) {
const id = win.webContents.id;
windowRegistry.set(id, { label, isMainWindow, win });
win.on('closed', () => windowRegistry.delete(id));
}
function createChildWindow(label: string, extraOpts: Partial<Electron.BrowserWindowConstructorOptions> = {}, tornOff = false): BrowserWindow {
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const opts: Electron.BrowserWindowConstructorOptions = {
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
frame: !isWindows,
backgroundColor: '#111111',
icon: appIcon,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
...extraOpts,
};
if (isMac) {
opts.titleBarStyle = 'hiddenInset';
opts.trafficLightPosition = { x: 16, y: 12 };
// vibrancy omitted for child windows — Electron 42 crashes in its Rust BMP compositor
// when a right-click sendEvent triggers image processing on a vibrancy-backed child window.
}
const win = new BrowserWindow(opts);
registerWindow(win, label, false);
if (isDev) {
win.loadURL(tornOff ? `${DEV_URL}?tornOff=1` : DEV_URL).catch(console.error);
} else {
void win.loadFile(
path.join(__dirname, '../ui/dist/index.html'),
tornOff ? { query: { tornOff: '1' } } : {},
);
}
win.webContents.on('will-navigate', (e, url) => {
if (url !== win.webContents.getURL()) e.preventDefault();
});
return win;
}
function createWindow(): BrowserWindow {
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
@@ -16,6 +85,7 @@ function createWindow(): BrowserWindow {
minHeight: 600,
frame: !isWindows,
backgroundColor: '#111111',
icon: appIcon,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
@@ -31,6 +101,7 @@ function createWindow(): BrowserWindow {
}
const win = new BrowserWindow(opts);
registerWindow(win, 'main', true);
if (isDev) {
win.loadURL(DEV_URL).catch(console.error);
@@ -39,7 +110,6 @@ function createWindow(): BrowserWindow {
void win.loadFile(path.join(__dirname, '../ui/dist/index.html'));
}
// Prevent external navigation from file drop
win.webContents.on('will-navigate', (e, url) => {
if (url !== win.webContents.getURL()) e.preventDefault();
});
@@ -48,63 +118,58 @@ function createWindow(): BrowserWindow {
}
app.whenReady().then(() => {
if (process.platform === 'darwin' && !appIcon.isEmpty()) {
app.dock?.setIcon(appIcon);
}
startBridge();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
// Global mouse hook — detects the actual mouse release for cross-window tab drops.
// HTML5 DnD events are sandboxed per renderer on macOS; this is the only reliable way
// to know the user released the mouse over a different BrowserWindow.
uIOhook.on('mouseup', (e) => {
if (e.button !== 1) return; // 1 = left button in uiohook-napi
if (!_activeDrag) return;
if (_dragPollingInterval) { clearInterval(_dragPollingInterval); _dragPollingInterval = null; }
const { x, y } = screen.getCursorScreenPoint();
const targetWin = windowAtPoint(x, y);
const sourceEntry = Array.from(windowRegistry.values()).find(v => v.label === _activeDrag!.sourceWindow);
if (targetWin && sourceEntry && targetWin.webContents.id !== sourceEntry.win.webContents.id) {
const drag = _activeDrag;
_dragWasAccepted = true;
_activeDrag = null;
targetWin.webContents.send('tab:cross-drop', { payload: drag.payload, sourceWindow: drag.sourceWindow });
} else {
// Released on source window or outside any window — broadcast cleanup.
_activeDrag = null;
for (const { win } of windowRegistry.values()) {
win.webContents.send('tab:drag-ended', {});
}
}
});
uIOhook.start();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
app.quit();
});
app.on('before-quit', () => {
uIOhook.stop();
stopBridge();
});
// ── Tear-off windows ─────────────────────────────────────────────────────────
// ── Legacy tear-off (window.api path) ────────────────────────────────────────
// Kept for compatibility with TitleBar's api.tearOffTab() call.
ipcMain.on('tearOffTab', (_e, tabJson: string) => {
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const opts: Electron.BrowserWindowConstructorOptions = {
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
frame: !isWindows,
backgroundColor: '#111111',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
};
if (isMac) {
opts.titleBarStyle = 'hiddenInset';
opts.trafficLightPosition = { x: 16, y: 12 };
opts.vibrancy = 'hud';
}
const win = new BrowserWindow(opts);
if (isDev) {
win.loadURL(`${DEV_URL}?tornOff=1`).catch(console.error);
} else {
void win.loadFile(path.join(__dirname, '../ui/dist/index.html'), { query: { tornOff: '1' } });
}
const label = `win-torn-${crypto.randomUUID().slice(0, 8)}`;
const win = createChildWindow(label, {}, true);
win.webContents.once('did-finish-load', () => {
win.webContents.send('restoreTornTab', tabJson);
});
win.webContents.on('will-navigate', (e, url) => {
if (url !== win.webContents.getURL()) e.preventDefault();
});
});
// ── Window controls ───────────────────────────────────────────────────────────
@@ -123,6 +188,158 @@ ipcMain.on('windowClose', (e) => {
BrowserWindow.fromWebContents(e.sender)?.close();
});
// ── window.byteDraft bridge support ──────────────────────────────────────────
// Sync call from preload: renderer needs its label + isMainWindow at startup.
ipcMain.on('get_window_info', (event) => {
const info = windowRegistry.get(event.sender.id);
event.returnValue = info
? { label: info.label, isMainWindow: info.isMainWindow }
: { label: 'unknown', isMainWindow: false };
});
// Cross-window event routing: forward payload to all windows except the sender.
ipcMain.handle('__emit__', (event, channel: string, payload: unknown) => {
for (const [id, { win }] of windowRegistry) {
if (id !== event.sender.id) {
win.webContents.send(channel, payload);
}
}
});
ipcMain.handle('get_window_labels', () =>
Array.from(windowRegistry.values()).map((v) => v.label),
);
// Child windows call this on mount to retrieve their tab payload (consumed once).
ipcMain.handle('get_window_payload', (event) => {
const info = windowRegistry.get(event.sender.id);
if (!info) return null;
const payload = windowPayloads.get(info.label) ?? null;
windowPayloads.delete(info.label);
return payload;
});
ipcMain.handle('set_window_payload', (_e, args: { label: string; payload: string }) => {
windowPayloads.set(args.label, args.payload);
});
// HTML5 DnD cross-window payload relay.
// dataTransfer.getData() is empty across renderer processes; these handlers let any
// window retrieve the drag payload that was set by the source window's dragstart.
ipcMain.handle('set_drag_payload', (_e, args: { payload: string; sourceWindow: string }) => {
let tabId: string | null = null;
try { tabId = (JSON.parse(args.payload) as { id?: string }).id ?? null; } catch { /* ignore */ }
_activeDrag = { payload: args.payload, sourceWindow: args.sourceWindow, tabId };
_dragWasAccepted = false;
});
ipcMain.handle('get_drag_payload', () => {
const drag = _activeDrag;
if (drag !== null) {
_dragWasAccepted = true;
_activeDrag = null;
}
return drag;
});
ipcMain.handle('was_drag_accepted', () => {
const result = _dragWasAccepted;
_dragWasAccepted = false;
return result;
});
// Main-process mouse tracking for cross-renderer tab drag-and-drop.
// HTML5 dragover/drop events are sandboxed per renderer process and never fire in
// a different BrowserWindow's DOM. Instead, we poll the cursor position here and
// route synthetic drag events to whichever window is under the cursor.
function windowAtPoint(x: number, y: number): BrowserWindow | null {
for (const { win } of windowRegistry.values()) {
if (win.isDestroyed()) continue;
const b = win.getBounds();
if (x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height) return win;
}
return null;
}
ipcMain.handle('start_drag_tracking', (event) => {
if (_dragPollingInterval) clearInterval(_dragPollingInterval);
const sourceId = event.sender.id;
_dragPollingInterval = setInterval(() => {
if (!_activeDrag) { clearInterval(_dragPollingInterval!); _dragPollingInterval = null; return; }
const { x, y } = screen.getCursorScreenPoint();
const targetWin = windowAtPoint(x, y);
if (!targetWin || targetWin.webContents.id === sourceId) return;
const bounds = targetWin.getBounds();
targetWin.webContents.send('tab:cross-drag-over', { clientX: x - bounds.x, clientY: y - bounds.y });
}, 16);
});
ipcMain.handle('finalize_cross_drag', (event) => {
if (_dragPollingInterval) { clearInterval(_dragPollingInterval); _dragPollingInterval = null; }
const { x, y } = screen.getCursorScreenPoint();
const targetWin = windowAtPoint(x, y);
if (targetWin && targetWin.webContents.id !== event.sender.id && _activeDrag) {
const drag = _activeDrag;
_dragWasAccepted = true;
_activeDrag = null;
targetWin.webContents.send('tab:cross-drop', { payload: drag.payload, sourceWindow: drag.sourceWindow });
}
});
// Target renderer calls this after accepting a cross-renderer drop; we notify the source to remove its copy.
ipcMain.handle('notify_drop_accepted', (_e, args: { sourceWindow: string; tabId?: string }) => {
const tabId = args.tabId ?? _activeDrag?.tabId ?? null;
_dragWasAccepted = true;
_activeDrag = null;
for (const { label, win } of windowRegistry.values()) {
if (label === args.sourceWindow) {
win.webContents.send('tab:drop-accepted', { tabId });
return;
}
}
});
// Route tab:hydrate to a specific window by label.
ipcMain.handle('send_tab_to_window', (_e, args: { label: string; payload: string }) => {
for (const { label, win } of windowRegistry.values()) {
if (label === args.label) {
win.webContents.send('tab:hydrate', args.payload);
return;
}
}
});
// Spawn a new visible child window, storing its payload for pull-model init.
ipcMain.handle('spawn_child_window', (_e, args: { label: string; payload: string; x?: number; y?: number }) => {
windowPayloads.set(args.label, args.payload);
const win = createChildWindow(args.label);
if (args.x !== undefined && args.y !== undefined) win.setPosition(args.x, args.y);
});
// Spawn a hidden child window for pre-warming.
ipcMain.handle('prewarm_child_window', (_e, args: { label: string }) => {
createChildWindow(args.label, { show: false });
});
// Show and position a previously pre-warmed hidden window.
ipcMain.handle('activate_prewarm_window', (_e, args: { label: string; x?: number; y?: number }) => {
for (const { label, win } of windowRegistry.values()) {
if (label === args.label) {
if (args.x !== undefined && args.y !== undefined) win.setPosition(args.x, args.y);
win.show();
win.focus();
return;
}
}
});
// Snake-case session commands used by window.byteDraft bridge
ipcMain.handle('load_session', () => callBridge('load_session'));
ipcMain.handle('save_session', (_e, args: { session: string }) =>
callBridge('save_session', { session: args.session }),
);
// ── Native dialogs (handled in Electron, not the Rust subprocess) ─────────────
ipcMain.handle('showMessageBox', async (e, opts: { title: string; message: string; kind: string }) => {
+25
View File
@@ -1,5 +1,30 @@
import { contextBridge, ipcRenderer } from 'electron';
// Retrieve window identity synchronously so it is available before React mounts.
const windowInfo = ipcRenderer.sendSync('get_window_info') as { label: string; isMainWindow: boolean };
// ── window.byteDraft — the cross-window bridge ────────────────────────────────
contextBridge.exposeInMainWorld('byteDraft', {
windowLabel: windowInfo.label,
isMainWindow: windowInfo.isMainWindow,
invoke: (cmd: string, args?: Record<string, unknown>) =>
ipcRenderer.invoke(cmd, args),
emit: (channel: string, payload?: unknown) =>
ipcRenderer.invoke('__emit__', channel, payload),
subscribe: (channel: string, handler: (payload: unknown) => void) => {
const listener = (_e: Electron.IpcRendererEvent, payload: unknown) => handler(payload);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
},
minimize: () => ipcRenderer.send('windowMinimize'),
toggleMaximize: () => ipcRenderer.send('windowToggleMaximize'),
closeWindow: () => ipcRenderer.send('windowClose'),
});
contextBridge.exposeInMainWorld('api', {
// ── File operations ─────────────────────────────────────────────────────────
openFile: (filePath: string) =>
+336 -4
View File
@@ -7,6 +7,10 @@
"": {
"name": "byte-draft",
"version": "0.2.0",
"hasInstallScript": true,
"dependencies": {
"uiohook-napi": "^1.5.5"
},
"devDependencies": {
"@electron-forge/cli": "^7",
"@electron-forge/maker-squirrel": "^7",
@@ -14,6 +18,8 @@
"@types/node": "^22",
"concurrently": "^9",
"electron": "^42.0.0",
"electron-playwright-helpers": "^2.1.0",
"electron-rebuild": "^3.2.9",
"typescript": "^5",
"wait-on": "^8"
}
@@ -936,7 +942,6 @@
"integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@inquirer/checkbox": "^3.0.1",
"@inquirer/confirm": "^4.0.1",
@@ -1545,7 +1550,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1612,7 +1616,6 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -1697,6 +1700,28 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aproba": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
"dev": true,
"license": "ISC"
},
"node_modules/are-we-there-yet": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz",
"integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==",
"deprecated": "This package is no longer supported.",
"dev": true,
"license": "ISC",
"dependencies": {
"delegates": "^1.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -1849,7 +1874,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -2262,6 +2286,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"dev": true,
"license": "ISC",
"bin": {
"color-support": "bin.js"
}
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -2350,6 +2384,13 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"dev": true,
"license": "ISC"
},
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
@@ -2514,6 +2555,13 @@
"node": ">=0.4.0"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2584,6 +2632,56 @@
"node": ">= 22.12.0"
}
},
"node_modules/electron-playwright-helpers": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/electron-playwright-helpers/-/electron-playwright-helpers-2.1.0.tgz",
"integrity": "sha512-aQOefS1irz/Ou6IYuTE34ZLmIKVHKoTGUwkVuwu2P5iguuMTLtsg6CFImsWu0cabRPVZ1NgEpcGPumFZNDdrqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@electron/asar": "^3.2.4"
}
},
"node_modules/electron-rebuild": {
"version": "3.2.9",
"resolved": "https://registry.npmjs.org/electron-rebuild/-/electron-rebuild-3.2.9.tgz",
"integrity": "sha512-FkEZNFViUem3P0RLYbZkUjC8LUFIK+wKq09GHoOITSJjfDAVQv964hwaNseTTWt58sITQX3/5fHNYcTefqaCWw==",
"deprecated": "Please use @electron/rebuild moving forward. There is no API change, just a package name change",
"dev": true,
"license": "MIT",
"dependencies": {
"@malept/cross-spawn-promise": "^2.0.0",
"chalk": "^4.0.0",
"debug": "^4.1.1",
"detect-libc": "^2.0.1",
"fs-extra": "^10.0.0",
"got": "^11.7.0",
"lzma-native": "^8.0.5",
"node-abi": "^3.0.0",
"node-api-version": "^0.1.4",
"node-gyp": "^9.0.0",
"ora": "^5.1.0",
"semver": "^7.3.5",
"tar": "^6.0.5",
"yargs": "^17.0.1"
},
"bin": {
"electron-rebuild": "lib/src/cli.js"
},
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/electron-rebuild/node_modules/node-api-version": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.1.4.tgz",
"integrity": "sha512-KGXihXdUChwJAOHO53bv9/vXcLmdUsZ6jIptbvYvkpKfth+r7jw44JkVxQFA3kX5nQjzjmGu1uAu/xNNLNlI5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.351",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.351.tgz",
@@ -2709,6 +2807,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -3298,6 +3421,66 @@
"node": ">= 12"
}
},
"node_modules/gauge": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz",
"integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==",
"deprecated": "This package is no longer supported.",
"dev": true,
"license": "ISC",
"dependencies": {
"aproba": "^1.0.3 || ^2.0.0",
"color-support": "^1.1.3",
"console-control-strings": "^1.1.0",
"has-unicode": "^2.0.1",
"signal-exit": "^3.0.7",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"wide-align": "^1.1.5"
},
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/gauge/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/gauge/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/gauge/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true,
"license": "ISC"
},
"node_modules/gauge/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -3590,6 +3773,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
@@ -4309,6 +4499,25 @@
"node": ">=12"
}
},
"node_modules/lzma-native": {
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/lzma-native/-/lzma-native-8.0.6.tgz",
"integrity": "sha512-09xfg67mkL2Lz20PrrDeNYZxzeW7ADtpYFbwSQh9U8+76RIzx5QsJBMy8qikv3hbUPfpy6hqwxt6FcGK81g9AA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^3.1.0",
"node-gyp-build": "^4.2.1",
"readable-stream": "^3.6.0"
},
"bin": {
"lzmajs": "bin/lzmajs"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/make-fetch-happen": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz",
@@ -4650,6 +4859,13 @@
"node": ">=10"
}
},
"node_modules/node-addon-api": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz",
"integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==",
"dev": true,
"license": "MIT"
},
"node_modules/node-api-version": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz",
@@ -4681,6 +4897,43 @@
}
}
},
"node_modules/node-gyp": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz",
"integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"glob": "^7.1.4",
"graceful-fs": "^4.2.6",
"make-fetch-happen": "^10.0.3",
"nopt": "^6.0.0",
"npmlog": "^6.0.0",
"rimraf": "^3.0.2",
"semver": "^7.3.5",
"tar": "^6.1.2",
"which": "^2.0.2"
},
"bin": {
"node-gyp": "bin/node-gyp.js"
},
"engines": {
"node": "^12.13 || ^14.13 || >=16"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-releases": {
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
@@ -4763,6 +5016,23 @@
"node": ">=4"
}
},
"node_modules/npmlog": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
"integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==",
"deprecated": "This package is no longer supported.",
"dev": true,
"license": "ISC",
"dependencies": {
"are-we-there-yet": "^3.0.0",
"console-control-strings": "^1.1.0",
"gauge": "^4.0.3",
"set-blocking": "^2.0.0"
},
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@@ -5682,6 +5952,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -6279,6 +6556,19 @@
"node": ">=14.17"
}
},
"node_modules/uiohook-napi": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.5.tgz",
"integrity": "sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
@@ -6542,6 +6832,48 @@
"node": ">= 8"
}
},
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/wide-align/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wide-align/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wide-align/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+8 -1
View File
@@ -8,7 +8,9 @@
"electron:compile": "tsc -p electron/tsconfig.json",
"electron:dev": "concurrently -k -n ui,electron \"npm --prefix ui run dev\" \"wait-on http://localhost:5173 -t 60000 && npm run electron:compile && electron . --dev\"",
"electron:build": "cargo build --release -p byte_draft_desktop && npm --prefix ui run build && npm run electron:compile && electron-forge make",
"test": "npm --prefix ui test"
"postinstall": "electron-rebuild -f -w uiohook-napi",
"test": "npm --prefix ui test",
"test:e2e:electron": "npm run electron:compile && npx playwright test --config=playwright.electron.config.ts"
},
"devDependencies": {
"@electron-forge/cli": "^7",
@@ -17,6 +19,8 @@
"@types/node": "^22",
"concurrently": "^9",
"electron": "^42.0.0",
"electron-playwright-helpers": "^2.1.0",
"electron-rebuild": "^3.2.9",
"typescript": "^5",
"wait-on": "^8"
},
@@ -45,5 +49,8 @@
}
]
}
},
"dependencies": {
"uiohook-napi": "^1.5.5"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from '@playwright/test';
import path from 'path';
export default defineConfig({
testDir: 'e2e/electron',
timeout: 30_000,
retries: 0,
workers: 1, // Electron tests must not run in parallel
use: {
// Passed through to each test via electronApp fixture
launchOptions: {
args: [path.join(__dirname, 'dist-electron/main.js'), '--dev'],
},
},
reporter: [['list'], ['html', { open: 'never' }]],
});
+24 -23
View File
@@ -19,6 +19,7 @@ import { Preferences } from './components/Preferences';
import { TweaksPanel } from './components/TweaksPanel';
import type { Tab } from './types';
import { THEME_PALETTES, type ThemePaletteName } from './theme/palettes';
import { listenForIncomingTab, maybeCloseWindow } from './state/windowBus';
function App() {
const [menuOpen, setMenuOpen] = useState(false);
@@ -33,18 +34,25 @@ function App() {
const tweaksPanelOpen = useUiStore((s) => s.tweaksPanelOpen);
const layoutMode = useUiStore((s) => s.layoutMode);
const accentColor = useUiStore((s) => s.accentColor);
const wordWrap = useUiStore((s) => s.wordWrap);
const showLineEndings = useUiStore((s) => s.showLineEndings);
const themeName = useSessionStore((s) => s.themeName);
const { openFile, saveFile, saveFileAs, openDroppedPaths } = useFileSystem();
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Boot: load session (or restore torn-off tab in detached windows)
// Suppress native right-click menu everywhere except the CodeMirror editor.
useEffect(() => {
document.addEventListener('contextmenu', (e) => {
const suppress = (e: Event) => {
if ((e.target as Element)?.closest?.('.cm-editor')) return;
e.preventDefault();
});
};
document.addEventListener('contextmenu', suppress);
return () => document.removeEventListener('contextmenu', suppress);
}, []);
// Boot: load session (or restore torn-off tab in detached windows)
useEffect(() => {
const isTornOff = new URLSearchParams(window.location.search).has('tornOff');
if (isTornOff) {
return api.onRestoreTornTab((tabJson) => {
@@ -56,6 +64,18 @@ function App() {
void loadSession();
}, []);
// Receive tabs moved from other windows (re-dock / cross-window moves).
useEffect(() => {
let unsubscribe: (() => void) | null = null;
void listenForIncomingTab().then((fn) => { unsubscribe = fn; });
return () => { unsubscribe?.(); };
}, []);
// Close child windows automatically when the last tab is removed.
useEffect(() => {
if (tabs.length === 0) void maybeCloseWindow();
}, [tabs.length]);
// Apply accent color as CSS variable on document root
useEffect(() => {
const colors: Record<string, string> = {
@@ -87,7 +107,7 @@ function App() {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, [tabs, activeTabId]);
}, [tabs, activeTabId, themeName, wordWrap, showLineEndings, layoutMode, accentColor]);
// Open dropped files/folders from the native window drag-and-drop API.
useEffect(() => {
@@ -212,7 +232,6 @@ function App() {
}
const showSidebar = layoutMode !== 'minimal';
const showRightPane = layoutMode === 'pane';
return (
<div className="flex flex-col h-screen select-none overflow-hidden" style={{ color: 'var(--text-primary)' }}>
@@ -237,24 +256,6 @@ function App() {
</div>
)}
{/* Right pane — shown only in pane layout */}
{showRightPane && (
<div className="w-56 shrink-0 border-l border-gray-700 bg-gray-900 overflow-y-auto p-3 text-xs text-gray-400">
{activeTab ? (
<>
<p className="font-semibold text-gray-300 mb-2">Document</p>
<div className="space-y-1">
<div className="flex justify-between"><span>Lines</span><span className="text-gray-200">{activeTab.content.split('\n').length}</span></div>
<div className="flex justify-between"><span>Words</span><span className="text-gray-200">{activeTab.content.split(/\s+/).filter(Boolean).length}</span></div>
<div className="flex justify-between"><span>Chars</span><span className="text-gray-200">{activeTab.content.length}</span></div>
<div className="flex justify-between"><span>Language</span><span className="text-gray-200">{activeTab.languageOverride ?? activeTab.language}</span></div>
</div>
</>
) : (
<span>No file open</span>
)}
</div>
)}
</div>
{/* Command Palette */}
+7
View File
@@ -32,6 +32,8 @@ export function AppMenu({ onClose }: AppMenuProps) {
const removeTab = useEditorStore((s) => s.removeTab);
const wordWrap = useUiStore((s) => s.wordWrap);
const setWordWrap = useUiStore((s) => s.setWordWrap);
const showLineEndings = useUiStore((s) => s.showLineEndings);
const setShowLineEndings = useUiStore((s) => s.setShowLineEndings);
const toggleSidebar = useUiStore((s) => s.toggleSidebar);
const toggleFindPanel = useUiStore((s) => s.toggleFindPanel);
const toggleCommandPalette = useUiStore((s) => s.toggleCommandPalette);
@@ -107,6 +109,11 @@ export function AppMenu({ onClose }: AppMenuProps) {
shortcut: '⌥Z',
action: () => setWordWrap(!wordWrap),
},
{
label: showLineEndings ? 'Hide Line Endings' : 'Show Line Endings',
shortcut: '⌥L',
action: () => setShowLineEndings(!showLineEndings),
},
{ label: 'Preferences…', shortcut: '⌘,', action: () => useUiStore.setState({ preferencesOpen: true }) },
],
},
+372
View File
@@ -0,0 +1,372 @@
import { useRef, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { DockviewReact, type DockviewReadyEvent, type DockviewApi, type SerializedDockview } from 'dockview';
import { useEditorStore } from '../../state/editorStore';
import { useUiStore } from '../../state/uiStore';
import { useSessionStore } from '../../state/sessionStore';
import { dockApiRegistry } from '../../state/dockApiRegistry';
import { tearOffTab, moveTabToWindow } from '../../state/windowBus';
import { FindReplace } from '../FindReplace';
import { panelComponents, componentForViewKind } from './DockPanels';
import { DockTab, TabContextMenu } from './DockTab';
import { DockRightActions } from './DockRightActions';
import type { Tab } from '../../types';
import './dockTheme.css';
import 'dockview/dist/styles/dockview.css';
// Synchronously detectable — child windows are spawned with ?role=child in the URL.
const isChildWindow = new URLSearchParams(window.location.search).get('role') === 'child';
function tabTitle(tab: Tab): string {
if (!tab.path) return 'Untitled';
return tab.path.split('/').pop() ?? 'Untitled';
}
export function DockContainer() {
const dockApiRef = useRef<DockviewApi | null>(null);
const dockElRef = useRef<HTMLDivElement | null>(null);
/** True while we're pushing changes to Dockview from the store, so we
* don't echo them back as a Dockview→store event. */
const suppressRef = useRef(false);
/** Panel ID currently being dragged by Dockview, or null when idle. */
const draggingPanelIdRef = useRef<string | null>(null);
/** Last known screen coordinates of the pointer during a drag. */
const lastScreenPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
/** Client position where the drag started, for threshold detection. */
const mousedownClientPosRef = useRef<{ x: number; y: number } | null>(null);
/** Title of the tab currently being dragged, for the ghost element. */
const draggingTabTitleRef = useRef<string>('');
/** Floating ghost tab shown while dragging a tab outside the tab strip. */
const [ghost, setGhost] = useState<{ title: string; x: number; y: number } | null>(null);
// ── Context menu ────────────────────────────────────────────────────────────
const [activeContextMenu, setActiveContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null);
// document-level capture fires before any element listener (including Dockview's
// own tab-header bubble listeners). We use elementsFromPoint rather than
// e.target/contains because Dockview places a transparent DnD overlay over the
// tab strip; the overlay is the event target, not the DockTab div.
// elementsFromPoint returns ALL stacked elements — it finds the DockTab (marked
// with data-dock-tab) even when an overlay sits on top of it.
useEffect(() => {
const handler = (e: MouseEvent) => {
const elements = document.elementsFromPoint(e.clientX, e.clientY);
const tabEl = elements.find((el) => el.hasAttribute('data-dock-tab'));
if (!tabEl) return;
const tabId = tabEl.getAttribute('data-dock-tab');
if (!tabId) return;
e.preventDefault();
e.stopImmediatePropagation();
setActiveContextMenu({ tabId, x: e.clientX, y: e.clientY });
};
document.addEventListener('contextmenu', handler, true);
return () => document.removeEventListener('contextmenu', handler, true);
}, []);
// Register/unregister with the module-level registry so saveSession can call toJSON()
useEffect(() => {
return () => {
if (dockApiRef.current && dockApiRegistry.get() === dockApiRef.current) {
dockApiRegistry.set(null);
}
};
}, []);
// Drag tear-off: detect cursor leaving the webview while a Dockview drag is
// active, then defer one rAF so Dockview can finish its own drag-cancel
// cleanup before we move the panel out of this window.
// Using mouseleave on document.documentElement rather than a mouseup coordinate
// check because Tauri/WebKit fires mouseup with the last known in-window
// coordinates when the button is released outside the OS window, making the
// coordinate check unreliable.
useEffect(() => {
const handleMouseLeave = () => {
const id = draggingPanelIdRef.current;
if (!id) return;
draggingPanelIdRef.current = null;
mousedownClientPosRef.current = null;
setGhost(null);
const { x, y } = lastScreenPosRef.current;
if (isChildWindow) {
void moveTabToWindow(id, 'main', x, y);
} else {
void tearOffTab(id, x, y);
}
};
document.documentElement.addEventListener('mouseleave', handleMouseLeave);
return () => document.documentElement.removeEventListener('mouseleave', handleMouseLeave);
}, []);
// Detect drag start: watch for mousedown on Dockview tab headers.
// onWillDragPanel does not exist in Dockview v5, so we intercept mousedown
// directly. Uses elementsFromPoint+data-dock-tab (same as context menu) because
// Dockview's DnD overlay sits on top of the tab strip and is the event target.
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return;
const elements = document.elementsFromPoint(e.clientX, e.clientY);
const tabEl = elements.find((el) => el.hasAttribute('data-dock-tab'));
if (!tabEl) return;
const tabId = tabEl.getAttribute('data-dock-tab');
if (!tabId) return;
draggingPanelIdRef.current = tabId;
mousedownClientPosRef.current = { x: e.clientX, y: e.clientY };
lastScreenPosRef.current = { x: window.screenX + e.clientX, y: window.screenY + e.clientY };
const tab = useEditorStore.getState().tabs.find((t) => t.id === tabId);
draggingTabTitleRef.current = tab?.path?.split('/').pop() ?? 'Untitled';
};
document.addEventListener('mousedown', handleMouseDown, true);
return () => document.removeEventListener('mousedown', handleMouseDown, true);
}, []);
// Backup exit detector: check if pointer coordinates leave the window during a drag.
// Catches the case where Dockview uses pointer capture, which suppresses mouseleave.
// Also tracks screen coordinates for window positioning and drives the ghost element.
useEffect(() => {
const handlePointerMove = (e: PointerEvent) => {
const id = draggingPanelIdRef.current;
if (!id) return;
lastScreenPosRef.current = { x: window.screenX + e.clientX, y: window.screenY + e.clientY };
if (
e.clientX < 0 ||
e.clientX > window.innerWidth ||
e.clientY < 0 ||
e.clientY > window.innerHeight
) {
draggingPanelIdRef.current = null;
mousedownClientPosRef.current = null;
setGhost(null);
const { x, y } = lastScreenPosRef.current;
if (isChildWindow) void moveTabToWindow(id, 'main', x, y);
else void tearOffTab(id, x, y);
return;
}
// Show the ghost after a 5px drag threshold so small accidental movements don't trigger it.
const start = mousedownClientPosRef.current;
if (start) {
const dist = Math.hypot(e.clientX - start.x, e.clientY - start.y);
if (dist > 5) {
setGhost({ title: draggingTabTitleRef.current, x: e.clientX, y: e.clientY });
}
}
};
document.addEventListener('pointermove', handlePointerMove, true);
return () => document.removeEventListener('pointermove', handlePointerMove, true);
}, []);
// Reset drag tracking when drag ends normally inside the window.
useEffect(() => {
const handleMouseUp = () => {
draggingPanelIdRef.current = null;
mousedownClientPosRef.current = null;
setGhost(null);
};
// Capture phase so we clear state even if Dockview stops propagation.
window.addEventListener('mouseup', handleMouseUp, true);
return () => window.removeEventListener('mouseup', handleMouseUp, true);
}, []);
const tabs = useEditorStore((s) => s.tabs);
const activeTabId = useEditorStore((s) => s.activeTabId);
const setActiveTab = useEditorStore((s) => s.setActiveTab);
const removeTab = useEditorStore((s) => s.removeTab);
const activeTab = tabs.find((t) => t.id === activeTabId) ?? null;
const findPanelOpen = useUiStore((s) => s.findPanelOpen);
// ── Store → Dockview sync ────────────────────────────────────────────────
useEffect(() => {
const api = dockApiRef.current;
if (!api) return;
suppressRef.current = true;
// Add panels that are in the store but not yet in Dockview
const existingIds = new Set(api.panels.map((p) => p.id));
for (const tab of tabs) {
if (!existingIds.has(tab.id)) {
api.addPanel({
id: tab.id,
component: componentForViewKind(tab.viewKind),
title: tabTitle(tab),
params: { tabId: tab.id },
});
}
}
// Remove panels that were removed from the store
const storeIds = new Set(tabs.map((t) => t.id));
for (const panel of api.panels) {
if (!storeIds.has(panel.id)) {
panel.api.close();
}
}
// Activate the correct panel
if (activeTabId) {
const panel = api.getPanel(activeTabId);
if (panel && !panel.api.isActive) {
panel.api.setActive();
}
}
suppressRef.current = false;
}, [tabs, activeTabId]);
// ── Dockview ready handler ───────────────────────────────────────────────
function handleReady(event: DockviewReadyEvent) {
const api = event.api;
dockApiRef.current = api;
dockApiRegistry.set(api);
const { tabs: currentTabs, activeTabId: currentActiveId } = useEditorStore.getState();
const savedLayout = useSessionStore.getState().dockLayout as SerializedDockview | null;
let layoutRestored = false;
if (savedLayout?.panels) {
// Only restore if the saved panel IDs exactly match the current store tab IDs.
// A mismatch means the session data was corrupted or the layout is stale.
const layoutIds = new Set(Object.keys(savedLayout.panels));
const storeIds = new Set(currentTabs.map((t) => t.id));
const consistent =
layoutIds.size === storeIds.size && [...layoutIds].every((id) => storeIds.has(id));
if (consistent) {
try {
api.fromJSON(savedLayout);
layoutRestored = true;
} catch {
// Layout restore failed (e.g. unknown component name after an update) — fall through
}
}
}
if (!layoutRestored) {
// Fallback: add panels in store order
for (const tab of currentTabs) {
api.addPanel({
id: tab.id,
component: componentForViewKind(tab.viewKind),
title: tabTitle(tab),
params: { tabId: tab.id },
});
}
if (currentActiveId) {
api.getPanel(currentActiveId)?.api.setActive();
}
}
// ── Dockview → Store events ────────────────────────────────────────────
api.onDidActivePanelChange((panel) => {
if (suppressRef.current || !panel) return;
setActiveTab(panel.id);
});
api.onDidRemovePanel((panel) => {
if (suppressRef.current) return;
const { tabs: t } = useEditorStore.getState();
if (t.find((tab) => tab.id === panel.id)) {
removeTab(panel.id);
}
});
// Sync reordering: when panels move within a group, update the store
api.onDidLayoutChange(() => {
if (suppressRef.current) return;
const panelOrder = api.panels.map((p) => p.id);
const { tabs: storeTabs } = useEditorStore.getState();
const storeOrder = storeTabs.map((t) => t.id);
const sameOrder =
panelOrder.length === storeOrder.length &&
panelOrder.every((id, i) => id === storeOrder[i]);
if (!sameOrder) {
const reordered = panelOrder
.map((id) => storeTabs.find((t) => t.id === id))
.filter((t): t is Tab => t !== undefined);
// Only update if all panels have matching tabs (avoid partial-sync during add/remove)
if (reordered.length === storeTabs.length) {
useEditorStore.setState({ tabs: reordered });
}
}
});
// ── Inject data-testid attributes for test compatibility ─────────────
const container = dockElRef.current;
if (container) {
const injectTestIds = () => {
const tabsAndActions = container.querySelector('.dv-tabs-and-actions-container');
if (tabsAndActions && !tabsAndActions.hasAttribute('data-testid')) {
tabsAndActions.setAttribute('data-testid', 'tab-bar');
tabsAndActions.classList.add('hide-scrollbar');
}
const tabsContainer = container.querySelector('.dv-tabs-container');
if (tabsContainer && !tabsContainer.hasAttribute('data-testid')) {
tabsContainer.setAttribute('data-testid', 'tab-scroll-region');
tabsContainer.classList.add('hide-scrollbar');
}
};
injectTestIds();
// Re-inject if DOM changes (e.g., first panel added creates the group DOM)
const observer = new MutationObserver(injectTestIds);
observer.observe(container, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 3000);
}
}
return (
<div className="flex-1 min-h-0 relative overflow-hidden">
<DockviewReact
ref={dockElRef}
onReady={handleReady}
components={panelComponents}
defaultTabComponent={DockTab}
rightHeaderActionsComponent={DockRightActions}
className="bytedraft-dock dockview-theme-dark"
/>
{activeTab?.viewKind === 'text' && findPanelOpen && <FindReplace />}
{activeContextMenu && (
<TabContextMenu
x={activeContextMenu.x}
y={activeContextMenu.y}
tabId={activeContextMenu.tabId}
onClose={() => setActiveContextMenu(null)}
/>
)}
{ghost && createPortal(
<div
style={{
position: 'fixed',
left: ghost.x - 40,
top: ghost.y - 14,
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px 12px',
background: 'var(--bg-surface)',
border: '1px solid var(--border-color)',
borderRadius: '6px 6px 0 0',
fontSize: '13px',
color: 'var(--text-primary)',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
opacity: 0.85,
pointerEvents: 'none',
zIndex: 99999,
whiteSpace: 'nowrap',
userSelect: 'none',
}}
>
<span>{ghost.title}</span>
<span style={{ opacity: 0.6, fontSize: '11px' }}></span>
</div>,
document.body
)}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { IDockviewPanelProps } from 'dockview';
import { useEditorStore } from '../../state/editorStore';
import { Editor } from '../Editor';
import { MarkdownPreview } from '../MarkdownPreview';
import { HexView } from '../HexView';
import { ImageView } from '../ImageView';
interface PanelParams {
tabId: string;
}
/**
* Universal panel component — reads the tab's current viewKind from the store
* so that view-mode switches (text → markdown, image → hex, etc.) are reflected
* immediately without needing to recreate the Dockview panel.
*/
function UniversalPanel({ params }: IDockviewPanelProps<PanelParams>) {
const tab = useEditorStore((s) => s.tabs.find((t) => t.id === params.tabId));
if (!tab) return null;
switch (tab.viewKind) {
case 'markdown':
case 'markdownPreviewOnly':
return <MarkdownPreview key={tab.id} tab={tab} />;
case 'hex':
return <HexView key={tab.id} tab={tab} />;
case 'image':
return <ImageView key={tab.id} tab={tab} />;
default:
return (
<div className="absolute inset-0 min-h-0">
<Editor key={tab.id} tab={tab} />
</div>
);
}
}
export const panelComponents: Record<string, React.FunctionComponent<IDockviewPanelProps>> = {
panel: UniversalPanel as React.FunctionComponent<IDockviewPanelProps>,
};
export function componentForViewKind(_viewKind: string): string {
return 'panel';
}
+101
View File
@@ -0,0 +1,101 @@
import type { IDockviewHeaderActionsProps } from 'dockview';
import { useEditorStore } from '../../state/editorStore';
import type { Tab } from '../../types';
// ── Markdown view selector icons ──────────────────────────────────────────────
function SourceIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4,3 1,7 4,11" />
<polyline points="10,3 13,7 10,11" />
<line x1="6" y1="11" x2="8" y2="3" />
</svg>
);
}
function SplitIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<rect x="1" y="2" width="5" height="10" rx="1" />
<rect x="8" y="2" width="5" height="10" rx="1" />
</svg>
);
}
function PreviewIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 2C4 2 1.5 7 1.5 7S4 12 7 12 12.5 7 12.5 7 10 2 7 2z" />
<circle cx="7" cy="7" r="1.8" />
</svg>
);
}
function MarkdownViewSelector() {
const tabs = useEditorStore((s) => s.tabs);
const activeTabId = useEditorStore((s) => s.activeTabId);
const updateTab = useEditorStore((s) => s.updateTab);
const activeTab = tabs.find((t) => t.id === activeTabId);
if (!activeTab || activeTab.language !== 'markdown') return null;
const currentMode =
activeTab.viewKind === 'markdown' ? 'split'
: activeTab.viewKind === 'markdownPreviewOnly' ? 'preview'
: 'source';
function modeBtn(
label: string,
icon: React.ReactNode,
mode: 'source' | 'split' | 'preview',
viewKind: Tab['viewKind'],
) {
const active = currentMode === mode;
return (
<button
key={mode}
onClick={() => updateTab(activeTab!.id, { viewKind })}
title={label}
aria-label={label}
className={`w-7 h-7 flex items-center justify-center rounded transition-colors ${
active
? 'bg-white/10 text-white'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-white/5'
}`}
>
{icon}
</button>
);
}
return (
<div className="flex items-center gap-0.5 shrink-0">
{modeBtn('Source', <SourceIcon />, 'source', 'text')}
{modeBtn('Split', <SplitIcon />, 'split', 'markdown')}
{modeBtn('Preview', <PreviewIcon />, 'preview', 'markdownPreviewOnly')}
</div>
);
}
// ── DockRightActions ──────────────────────────────────────────────────────────
export function DockRightActions(_props: IDockviewHeaderActionsProps) {
const addTab = useEditorStore((s) => s.addTab);
return (
<div data-testid="tab-controls-right" className="flex items-center gap-0.5 px-2 shrink-0 h-full">
<MarkdownViewSelector />
<button
type="button"
onClick={() => addTab({})}
title="New Tab (⌘T)"
aria-label="New Tab"
className="w-7 h-7 flex items-center justify-center rounded text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-white/5 transition-colors"
>
+
</button>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import type { IDockviewPanelHeaderProps } from 'dockview';
import { useEditorStore } from '../../state/editorStore';
import type { Tab } from '../../types';
function tabTitle(tab: Tab): string {
if (!tab.path) return 'Untitled';
return tab.path.split('/').pop() ?? 'Untitled';
}
// ── Context menu ──────────────────────────────────────────────────────────────
interface TabContextMenuProps {
x: number;
y: number;
tabId: string;
onClose: () => void;
}
export function TabContextMenu({ x, y, tabId, onClose }: TabContextMenuProps) {
const removeTab = useEditorStore((s) => s.removeTab);
const tabs = useEditorStore((s) => s.tabs);
const tab = tabs.find((t) => t.id === tabId);
if (!tab) return null;
function confirmClose(t: Tab): boolean {
if (!t.isDirty) return true;
return window.confirm(`"${tabTitle(t)}" has unsaved changes. Close anyway?`);
}
function handleClose() {
if (confirmClose(tab!)) removeTab(tab!.id);
onClose();
}
function closeOthers() {
tabs
.filter((t) => t.id !== tab!.id)
.forEach((t) => { if (confirmClose(t)) removeTab(t.id); });
onClose();
}
function closeAll() {
tabs.forEach((t) => { if (confirmClose(t)) removeTab(t.id); });
onClose();
}
function closeUnmodified() {
tabs.filter((t) => !t.isDirty).forEach((t) => removeTab(t.id));
onClose();
}
function closeToLeft() {
const idx = tabs.findIndex((t) => t.id === tab!.id);
if (idx > 0) tabs.slice(0, idx).forEach((t) => { if (confirmClose(t)) removeTab(t.id); });
onClose();
}
function closeToRight() {
const idx = tabs.findIndex((t) => t.id === tab!.id);
if (idx !== -1 && idx < tabs.length - 1)
tabs.slice(idx + 1).forEach((t) => { if (confirmClose(t)) removeTab(t.id); });
onClose();
}
async function moveToNewWindow() {
onClose();
const { tearOffTab } = await import('../../state/windowBus');
await tearOffTab(tab!.id);
}
const itemClass = 'w-full text-left px-3 py-1.5 text-sm hover:bg-white/8 text-[var(--text-primary)]';
const items = [
{ label: 'Close', action: handleClose },
{ label: 'Close Other Tabs', action: closeOthers },
{ label: 'Close All Tabs', action: closeAll },
{ label: 'Close Unmodified Tabs', action: closeUnmodified },
{ label: 'Close Tabs to Left', action: closeToLeft },
{ label: 'Close Tabs to Right', action: closeToRight },
null, // separator
{ label: 'Move to New Window', action: () => void moveToNewWindow() },
];
return (
<>
<div className="fixed inset-0 z-[100]" aria-hidden onClick={onClose} />
<div
role="menu"
data-testid="tab-context-menu"
className="fixed z-[101] rounded shadow-xl py-1 min-w-[200px] text-sm border"
style={{ left: x, top: y, backgroundColor: 'var(--bg-surface)', borderColor: 'var(--border-color)' }}
>
{items.map((item, i) =>
item === null ? (
<div key={i} className="my-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
) : (
<button key={item.label} type="button" role="menuitem" className={itemClass} onClick={item.action}>
{item.label}
</button>
),
)}
<MoveToWindowMenu tabId={tab.id} onClose={onClose} itemClass={itemClass} />
</div>
</>
);
}
// ── Move-to-window submenu ────────────────────────────────────────────────────
import { useState, useEffect } from 'react';
function MoveToWindowMenu({ tabId, onClose, itemClass }: { tabId: string; onClose: () => void; itemClass: string }) {
const [otherWindows, setOtherWindows] = useState<string[]>([]);
const [submenuOpen, setSubmenuOpen] = useState(false);
useEffect(() => {
async function fetchWindows() {
const { getWindowLabels } = await import('../../state/windowBus');
const labels = await getWindowLabels();
if (!labels.length) return;
const { getDesktopBridge } = await import('../../desktop-bridge');
const currentLabel = getDesktopBridge()?.windowLabel ?? 'main';
setOtherWindows(labels.filter((l) => l !== currentLabel));
}
void fetchWindows();
}, []);
if (otherWindows.length === 0) return null;
const subItemClass = 'w-full text-left px-3 py-1.5 text-sm hover:bg-white/8 text-[var(--text-secondary)]';
async function moveToWindow(targetLabel: string) {
onClose();
const { moveTabToWindow } = await import('../../state/windowBus');
await moveTabToWindow(tabId, targetLabel);
}
return (
<div className="relative">
<button
type="button"
role="menuitem"
className={`${itemClass} flex items-center justify-between`}
onMouseEnter={() => setSubmenuOpen(true)}
onMouseLeave={() => setSubmenuOpen(false)}
>
<span>Move to Window</span>
<span className="text-[var(--text-muted)] ml-2"></span>
</button>
{submenuOpen && (
<div
className="absolute left-full top-0 rounded shadow-xl py-1 min-w-[140px] text-sm border"
style={{ backgroundColor: 'var(--bg-surface)', borderColor: 'var(--border-color)' }}
onMouseEnter={() => setSubmenuOpen(true)}
onMouseLeave={() => setSubmenuOpen(false)}
>
{otherWindows.map((label) => (
<button
key={label}
type="button"
role="menuitem"
className={subItemClass}
onClick={() => void moveToWindow(label)}
>
{label === 'main' ? 'Main Window' : label}
</button>
))}
</div>
)}
</div>
);
}
// ── DockTab ───────────────────────────────────────────────────────────────────
export function DockTab({ api, params }: IDockviewPanelHeaderProps<{ tabId: string }>) {
const tab = useEditorStore((s) => s.tabs.find((t) => t.id === params.tabId));
const isActive = api.isActive;
if (!tab) return null;
const title = tabTitle(tab);
const displayTitle = tab.isDirty ? `${title}` : title;
function handleClose(e: React.MouseEvent) {
e.stopPropagation();
if (!tab) return;
if (tab.isDirty && !window.confirm(`"${title}" has unsaved changes. Close anyway?`)) return;
useEditorStore.getState().removeTab(tab.id);
}
return (
<div
data-dock-tab={tab.id}
className={`relative flex items-center h-full group ${isActive ? 'border-b-2 border-[var(--accent)]' : ''}`}
>
<button
type="button"
data-testid={`tab-btn-${tab.id}`}
data-tab-active={isActive ? 'true' : 'false'}
title={tab.path ?? 'Untitled'}
className={`px-3 text-sm max-w-[140px] truncate h-full flex items-center ${
isActive
? 'text-[var(--text-primary)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-white/5'
}`}
onClick={() => api.setActive()}
>
{displayTitle}
</button>
<button
type="button"
onClick={handleClose}
title="Close Tab (⌘W)"
aria-label={`Close ${title}`}
className="mr-1 w-4 h-4 flex items-center justify-center text-xs text-[var(--text-muted)] hover:text-white hover:bg-white/10 rounded opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
>
×
</button>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
/* ByteDraft Notepads Dark theme for Dockview */
.bytedraft-dock {
--dv-background-color: var(--bg-primary);
--dv-group-view-background-color: var(--bg-primary);
--dv-tabs-and-actions-container-background-color: var(--bg-surface);
--dv-tabs-and-actions-container-divider-color: var(--border-color);
/* Active group tab colours */
--dv-activegroup-visiblepanel-tab-background-color: var(--bg-primary);
--dv-activegroup-visiblepanel-tab-color: var(--text-primary);
--dv-activegroup-hiddenpanel-tab-background-color: var(--bg-surface);
--dv-activegroup-hiddenpanel-tab-color: var(--text-muted);
/* Inactive group tab colours */
--dv-inactivegroup-visiblepanel-tab-background-color: var(--bg-surface);
--dv-inactivegroup-visiblepanel-tab-color: var(--text-muted);
--dv-inactivegroup-hiddenpanel-tab-background-color: var(--bg-surface);
--dv-inactivegroup-hiddenpanel-tab-color: var(--text-muted);
/* Separators */
--dv-separator-border: var(--border-color);
--dv-paneview-header-background-color: var(--bg-surface);
/* Drag overlay */
--dv-drag-over-background-color: rgba(255, 255, 255, 0.05);
--dv-drag-over-border-color: var(--accent);
/* Scrollbar */
--dv-tabs-container-scrollbar-color: transparent;
}
/* Remove Dockview's built-in tab background / padding — we use our own via DockTab */
.bytedraft-dock .dv-tab {
background: transparent !important;
border: none !important;
padding: 0 !important;
height: 100%;
}
.bytedraft-dock .dv-tabs-and-actions-container {
border-bottom: 1px solid var(--border-color);
height: 36px;
}
.bytedraft-dock .dv-tabs-container {
overflow-x: auto;
overflow-y: hidden;
display: flex;
align-items: stretch;
}
/* Hide Dockview's built-in scrollbar on the tab strip */
.bytedraft-dock .dv-tabs-container::-webkit-scrollbar {
display: none;
}
.bytedraft-dock .dv-tabs-container {
scrollbar-width: none;
-ms-overflow-style: none;
}
/* Content area */
.bytedraft-dock .dv-content-container {
background: var(--bg-primary);
position: relative;
}
/* Resize handle */
.bytedraft-dock .dv-resize-handle:hover {
background: var(--accent);
}
/* Drop target overlay */
.bytedraft-dock .dv-drop-target .dv-drop-target-anchor {
background: rgba(255, 255, 255, 0.05);
border: 2px solid var(--accent);
}
+35 -14
View File
@@ -1,4 +1,6 @@
import { useEffect, useState } from 'react';
import type { Tab } from '../../types';
import { api } from '../../api';
interface HexViewProps {
tab: Tab;
@@ -7,10 +9,6 @@ interface HexViewProps {
const MAX_BYTES = 4096;
const BYTES_PER_ROW = 16;
function toBytes(str: string): Uint8Array {
return new TextEncoder().encode(str);
}
function isPrintable(byte: number): boolean {
return byte >= 0x20 && byte < 0x7f;
}
@@ -39,20 +37,44 @@ function HexRow({ offset, bytes }: HexRowProps) {
}
}
const hexStr = hexCells.join(' ');
const asciiStr = asciiCells.join('');
return (
<div className="flex gap-4 leading-5">
<span className="text-gray-500 select-none">{padHex(offset, 8)}</span>
<span className="text-green-400">{hexStr}</span>
<span className="text-gray-300">{asciiStr}</span>
<span className="text-green-400">{hexCells.join(' ')}</span>
<span className="text-gray-300">{asciiCells.join('')}</span>
</div>
);
}
export function HexView({ tab }: HexViewProps) {
if (tab.content.length === 0) {
const [bytes, setBytes] = useState<Uint8Array | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!tab.path) {
setBytes(new Uint8Array(0));
setLoading(false);
return;
}
setLoading(true);
api.readBinary(tab.path)
.then((raw) => setBytes(new Uint8Array(raw)))
.catch(() => setBytes(new Uint8Array(0)))
.finally(() => setLoading(false));
}, [tab.path]);
if (loading) {
return (
<div
data-testid="hex-view"
className="flex h-full items-center justify-center text-gray-500 text-sm font-mono"
>
Loading
</div>
);
}
if (!bytes || bytes.length === 0) {
return (
<div
data-testid="hex-view"
@@ -63,9 +85,8 @@ export function HexView({ tab }: HexViewProps) {
);
}
const allBytes = toBytes(tab.content);
const displayBytes = allBytes.slice(0, MAX_BYTES);
const truncated = allBytes.length > MAX_BYTES;
const displayBytes = bytes.slice(0, MAX_BYTES);
const truncated = bytes.length > MAX_BYTES;
const rows: { offset: number; slice: Uint8Array }[] = [];
for (let i = 0; i < displayBytes.length; i += BYTES_PER_ROW) {
@@ -79,7 +100,7 @@ export function HexView({ tab }: HexViewProps) {
))}
{truncated && (
<div className="mt-2 text-gray-500 text-xs">
({allBytes.length.toLocaleString()} bytes total)
({bytes.length.toLocaleString()} bytes total)
</div>
)}
</div>
@@ -3,7 +3,7 @@ import { useUiStore } from '../../state/uiStore';
import { useSessionStore } from '../../state/sessionStore';
import { THEME_PALETTE_NAMES } from '../../theme/palettes';
const LAYOUT_MODES = ['classic', 'minimal', 'pane'] as const;
const LAYOUT_MODES = ['classic', 'minimal'] as const;
export function Preferences() {
const preferencesOpen = useUiStore((s) => s.preferencesOpen);
+347 -26
View File
@@ -1,8 +1,9 @@
import { useRef, useState, useEffect, type MouseEvent } from 'react';
import { useRef, useState, useEffect, useCallback, Fragment, type MouseEvent } from 'react';
import {
DndContext,
closestCenter,
PointerSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
@@ -15,6 +16,9 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useEditorStore } from '../../state/editorStore';
import { moveTabToWindow, serializeTabForTransfer, ingestSerializedTab } from '../../state/windowBus';
import type { SerializedTab } from '../../state/windowBus';
import { getDesktopBridge } from '../../desktop-bridge';
import { api } from '../../api';
import type { Tab } from '../../types';
@@ -48,6 +52,16 @@ function scrollTabCleanlyIntoView(container: HTMLDivElement, tabEl: HTMLElement)
}
}
function DropIndicator() {
return (
<div
aria-hidden
className="shrink-0 self-stretch w-0.5 rounded-full pointer-events-none"
style={{ backgroundColor: 'var(--accent)' }}
/>
);
}
// ── SortableTab ───────────────────────────────────────────────────────────────
interface SortableTabProps {
@@ -56,9 +70,11 @@ interface SortableTabProps {
onActivate: () => void;
onClose: () => void;
onRequestContextMenu: (e: MouseEvent, tab: Tab) => void;
onHtml5DragStart: (e: React.DragEvent, tab: Tab) => void;
onHtml5DragEnd: (e: React.DragEvent, tab: Tab) => void;
}
function SortableTab({ tab, isActive, onActivate, onClose, onRequestContextMenu }: SortableTabProps) {
function SortableTab({ tab, isActive, onActivate, onClose, onRequestContextMenu, onHtml5DragStart, onHtml5DragEnd }: SortableTabProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id });
const style: React.CSSProperties = {
@@ -68,13 +84,16 @@ function SortableTab({ tab, isActive, onActivate, onClose, onRequestContextMenu
};
const title = tabTitle(tab);
const displayTitle = tab.isDirty ? `${title}` : title;
return (
<div
ref={setNodeRef}
style={style}
data-tab-id={tab.id}
data-electron-no-drag
draggable
onDragStart={(e) => onHtml5DragStart(e, tab)}
onDragEnd={(e) => onHtml5DragEnd(e, tab)}
className={`relative flex items-center shrink-0 h-full group ${
isActive ? 'border-b-2 border-[var(--accent)]' : ''
}`}
@@ -88,13 +107,16 @@ function SortableTab({ tab, isActive, onActivate, onClose, onRequestContextMenu
data-testid={`tab-btn-${tab.id}`}
data-tab-active={isActive ? 'true' : 'false'}
title={tab.path ?? 'Untitled'}
className={`px-3 text-sm transition-colors max-w-[140px] truncate cursor-grab active:cursor-grabbing h-full flex items-center ${
className={`px-3 text-sm transition-colors whitespace-nowrap cursor-grab active:cursor-grabbing h-full flex items-center gap-1.5 ${
isActive
? 'text-[var(--text-primary)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-white/5'
}`}
>
{displayTitle}
{tab.isDirty && (
<span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] shrink-0" aria-label="Unsaved" />
)}
{title}
</button>
<button
@@ -123,13 +145,26 @@ interface TabContextMenuProps {
onCloseUnmodified: () => void;
onCloseToLeft: (id: string) => void;
onCloseToRight: (id: string) => void;
onMoveToMain?: () => void;
}
function TabContextMenu({
x, y, tab, onClose,
onCloseTab, onCloseOthers, onCloseAll, onCloseUnmodified, onCloseToLeft, onCloseToRight,
onMoveToMain,
}: TabContextMenuProps) {
const menuItemClass = 'w-full text-left px-3 py-1.5 text-sm hover:bg-white/8 text-[var(--text-primary)]';
const items: Array<{ label: string; action: () => void }> = [
{ label: 'Close', action: () => { onCloseTab(tab); onClose(); } },
{ label: 'Close Other Tabs', action: () => { onCloseOthers(tab.id); onClose(); } },
{ label: 'Close All Tabs', action: () => { onCloseAll(); onClose(); } },
{ label: 'Close Unmodified Tabs', action: () => { onCloseUnmodified(); onClose(); } },
{ label: 'Close Tabs to Left', action: () => { onCloseToLeft(tab.id); onClose(); } },
{ label: 'Close Tabs to Right', action: () => { onCloseToRight(tab.id); onClose(); } },
];
if (onMoveToMain) {
items.push({ label: 'Move to Main Window', action: () => { onMoveToMain(); onClose(); } });
}
return (
<>
<div className="fixed inset-0 z-[100]" aria-hidden onClick={onClose} />
@@ -144,14 +179,7 @@ function TabContextMenu({
borderColor: 'var(--border-color)',
}}
>
{[
{ label: 'Close', action: () => { onCloseTab(tab); onClose(); } },
{ label: 'Close Other Tabs', action: () => { onCloseOthers(tab.id); onClose(); } },
{ label: 'Close All Tabs', action: () => { onCloseAll(); onClose(); } },
{ label: 'Close Unmodified Tabs', action: () => { onCloseUnmodified(); onClose(); } },
{ label: 'Close Tabs to Left', action: () => { onCloseToLeft(tab.id); onClose(); } },
{ label: 'Close Tabs to Right', action: () => { onCloseToRight(tab.id); onClose(); } },
].map(({ label, action }) => (
{items.map(({ label, action }) => (
<button key={label} type="button" role="menuitem" className={menuItemClass} onClick={action}>
{label}
</button>
@@ -171,8 +199,20 @@ export function TabStrip() {
const reorderTabs = useEditorStore((s) => s.reorderTabs);
const [tabContextMenu, setTabContextMenu] = useState<{ x: number; y: number; tab: Tab } | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const suppressActivateRef = useRef(false);
const tabStripRef = useRef<HTMLDivElement>(null);
const isChildWindow = !(getDesktopBridge()?.isMainWindow ?? true);
const windowLabel = getDesktopBridge()?.windowLabel ?? 'browser';
// HTML5 DnD cross-window tracking refs (not state — mutations must not re-render)
const h5DroppedInSameWindowRef = useRef(false);
// True while another window has an active tab drag (set via IPC broadcast).
// dataTransfer.types does not propagate custom MIME types across renderer processes,
// so we can't rely on types.includes('bytedraft/tab') to gate dragover in the target.
const crossWindowDragActiveRef = useRef(false);
// Mirrors dragOverIndex state so window-level listeners read current value without stale closures.
const dragOverIndexRef = useRef<number | null>(null);
useEffect(() => {
if (!tabStripRef.current || !activeTabId) return;
@@ -182,7 +222,109 @@ export function TabStrip() {
scrollTabCleanlyIntoView(tabStrip, el);
}, [activeTabId]);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
// Track cross-window tab drags via IPC broadcast and main-process mouse polling.
useEffect(() => {
const b = getDesktopBridge();
if (!b) return;
const unsubStart = b.subscribe('tab:drag-started', () => {
console.log('[DnD] received tab:drag-started in window', windowLabel);
crossWindowDragActiveRef.current = true;
});
const unsubEnd = b.subscribe('tab:drag-ended', () => {
console.log('[DnD] received tab:drag-ended in window', windowLabel);
crossWindowDragActiveRef.current = false;
dragOverIndexRef.current = null;
setDragOverIndex(null);
});
// Main process routes cursor position here while a cross-renderer drag is in flight.
// This IPC path is NOT throttled by macOS (main→renderer), so it arrives during the drag
// while renderer→renderer IPC (tab:drag-started) is batched until after the drag ends.
// Setting crossWindowDragActiveRef here is the only reliable way to prime the drop state.
const unsubCrossOver = b.subscribe('tab:cross-drag-over', (data: unknown) => {
const { clientX, clientY } = data as { clientX: number; clientY: number };
crossWindowDragActiveRef.current = true;
if (!tabStripRef.current) return;
const r = tabStripRef.current.getBoundingClientRect();
if (clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom) {
if (dragOverIndexRef.current !== null) { dragOverIndexRef.current = null; setDragOverIndex(null); }
return;
}
const idx = calcInsertIndex(clientX);
if (dragOverIndexRef.current !== idx) { dragOverIndexRef.current = idx; setDragOverIndex(idx); }
});
// Main process fires this when the drag ends over this window (target role, fallback for
// when finalize_cross_drag is called from a dragend event that actually fires).
const unsubCrossDrop = b.subscribe('tab:cross-drop', (data: unknown) => {
console.log('[DnD] tab:cross-drop received');
const { payload, sourceWindow } = data as { payload: string; sourceWindow: string };
const insertAt = dragOverIndexRef.current;
dragOverIndexRef.current = null;
setDragOverIndex(null);
crossWindowDragActiveRef.current = false;
let tabId: string | undefined;
try {
const serialized = JSON.parse(payload) as SerializedTab;
tabId = serialized.id;
ingestSerializedTab(serialized);
if (insertAt !== null) {
const store = useEditorStore.getState();
const idx = store.tabs.findIndex((t) => t.id === serialized.id);
if (idx !== -1 && idx !== insertAt) store.reorderTabs(idx, insertAt);
}
} catch { /* malformed payload */ }
void b.invoke('notify_drop_accepted', { sourceWindow, tabId });
});
// Main process fires this when another window accepted our tab (source role).
const unsubDropAccepted = b.subscribe('tab:drop-accepted', (data: unknown) => {
const { tabId } = data as { tabId?: string };
console.log('[DnD] tab:drop-accepted, removing tab', tabId);
if (tabId) useEditorStore.getState().removeTabSilently(tabId);
void b.emit('tab:drag-ended', {});
if (isChildWindow && useEditorStore.getState().tabs.length === 0) {
void b.closeWindow();
}
});
// dragend doesn't fire cross-renderer in Electron; use mouseup in the TARGET window instead.
// Raw mouse events cross Electron windows; HTML5 DnD events don't.
function onMouseUp(e: MouseEvent) {
if (e.button !== 0) return;
console.log('[DnD] onMouseUp fired: crossActive=', crossWindowDragActiveRef.current, 'overIndex=', dragOverIndexRef.current);
if (!crossWindowDragActiveRef.current) return;
if (dragOverIndexRef.current === null) return; // cursor wasn't over our tab strip
const insertAt = dragOverIndexRef.current;
dragOverIndexRef.current = null;
setDragOverIndex(null);
crossWindowDragActiveRef.current = false;
console.log('[DnD] mouseup during cross-drag, insertAt=', insertAt);
void b.invoke<{ payload: string; sourceWindow: string } | null>('get_drag_payload').then((drag) => {
if (!drag) { console.log('[DnD] mouseup: no drag payload'); return; }
let tabId: string | undefined;
try {
const serialized = JSON.parse(drag.payload) as SerializedTab;
tabId = serialized.id;
ingestSerializedTab(serialized);
if (insertAt !== null) {
const store = useEditorStore.getState();
const idx = store.tabs.findIndex((t) => t.id === serialized.id);
if (idx !== -1 && idx !== insertAt) store.reorderTabs(idx, insertAt);
}
} catch { /* malformed */ }
void b.invoke('notify_drop_accepted', { sourceWindow: drag.sourceWindow, tabId });
});
}
window.addEventListener('mouseup', onMouseUp, true);
return () => {
unsubStart(); unsubEnd(); unsubCrossOver(); unsubCrossDrop(); unsubDropAccepted();
window.removeEventListener('mouseup', onMouseUp, true);
};
}, []);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
);
const tabIds = tabs.map((t) => t.id);
function handleDragEnd(event: DragEndEvent) {
@@ -190,7 +332,8 @@ export function TabStrip() {
setTimeout(() => { suppressActivateRef.current = false; }, 0);
// Tear-off: tab dragged more than TEAR_THRESHOLD px below the title bar
if (activatorEvent instanceof PointerEvent) {
// MouseSensor fires MouseEvent (not PointerEvent) so check the superclass.
if (activatorEvent instanceof MouseEvent) {
const finalY = activatorEvent.clientY + delta.y;
if (finalY > TITLE_BAR_HEIGHT + TEAR_THRESHOLD) {
const tab = tabs.find((t) => t.id === String(active.id));
@@ -232,28 +375,205 @@ export function TabStrip() {
if (idx !== -1 && idx < tabs.length - 1) closeTabs(tabs.slice(idx + 1).map((t) => t.id));
}
// ── HTML5 DnD — cross-window drag ─────────────────────────────────────────
// Pointer Events (used by dnd-kit) are window-scoped and cannot cross Electron
// window boundaries. HTML5 DnD is dispatched by Chromium at the OS level and
// CAN cross windows in the same Electron process.
function handleHtml5DragStart(e: React.DragEvent, tab: Tab) {
const payload = serializeTabForTransfer(tab.id);
if (!payload) { e.preventDefault(); return; }
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('bytedraft/tab', payload);
e.dataTransfer.setData('bytedraft/source-window', windowLabel);
h5DroppedInSameWindowRef.current = false;
const b = getDesktopBridge();
console.log('[DnD] dragStart: tab', tab.id, 'window', windowLabel, 'bridge=', !!b);
if (b) {
void b.invoke('set_drag_payload', { payload, sourceWindow: windowLabel });
void b.invoke('start_drag_tracking', {});
void b.emit('tab:drag-started', {});
console.log('[DnD] emitted tab:drag-started, set_drag_payload called');
}
}
async function handleHtml5DragEnd(_e: React.DragEvent, tab: Tab) {
const b = getDesktopBridge();
console.log('[DnD] dragEnd fired: tab=', tab.id, 'sameWindow=', h5DroppedInSameWindowRef.current);
if (h5DroppedInSameWindowRef.current) {
h5DroppedInSameWindowRef.current = false;
if (b) void b.emit('tab:drag-ended', {});
return;
}
// Let main process finalize: stops polling, fires tab:cross-drop to target if cursor
// is over another window. MUST run before emitting tab:drag-ended — otherwise the
// target resets dragOverIndexRef before tab:cross-drop arrives.
if (b) await b.invoke('finalize_cross_drag');
// dropEffect is unreliable across renderer processes; query the main process instead.
const accepted = b ? await b.invoke<boolean>('was_drag_accepted') : false;
if (accepted) {
// Target window consumed the payload — remove from this window.
useEditorStore.getState().removeTabSilently(tab.id);
if (b) void b.emit('tab:drag-ended', {});
return;
}
// No window accepted the drop → tear off into its own window.
if (b) void b.emit('tab:drag-ended', {});
removeTab(tab.id);
api.tearOffTab(JSON.stringify(tab));
}
function calcInsertIndex(clientX: number): number {
const tabEls = tabStripRef.current?.querySelectorAll('[data-tab-id]');
if (!tabEls) return 0;
for (let i = 0; i < tabEls.length; i++) {
const rect = (tabEls[i] as HTMLElement).getBoundingClientRect();
if (clientX < rect.left + rect.width / 2) return i;
}
return tabEls.length;
}
function isOverTabStrip(e: DragEvent): boolean {
if (!tabStripRef.current) return false;
const r = tabStripRef.current.getBoundingClientRect();
return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
}
// Window-level DnD: element-level dragover never fires in Electron's hiddenInset title bar
// (the native compositor layer intercepts events before they reach DOM elements there).
// Listening at the window level bypasses that and catches both same-renderer and cross-renderer drags.
useEffect(() => {
function onDragOver(e: DragEvent) {
const hasType = e.dataTransfer?.types.includes('bytedraft/tab') ?? false;
const crossActive = crossWindowDragActiveRef.current;
console.log('[DnD] onDragOver: hasType=', hasType, 'crossActive=', crossActive);
if (!hasType && !crossActive) return;
if (!isOverTabStrip(e)) {
if (dragOverIndexRef.current !== null) {
dragOverIndexRef.current = null;
setDragOverIndex(null);
}
return;
}
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const idx = calcInsertIndex(e.clientX);
if (dragOverIndexRef.current !== idx) {
dragOverIndexRef.current = idx;
setDragOverIndex(idx);
}
console.log('[DnD] window.dragOver: idx=', idx, 'hasType=', hasType, 'crossActive=', crossActive);
}
function onDrop(e: DragEvent) {
const hasType = e.dataTransfer?.types.includes('bytedraft/tab') ?? false;
const crossActive = crossWindowDragActiveRef.current;
console.log('[DnD] window.drop: hasType=', hasType, 'crossActive=', crossActive, 'overStrip=', isOverTabStrip(e));
if (!hasType && !crossActive) return;
if (!isOverTabStrip(e)) return;
e.preventDefault();
const insertAt = dragOverIndexRef.current;
dragOverIndexRef.current = null;
setDragOverIndex(null);
crossWindowDragActiveRef.current = false;
if (hasType) {
// Same renderer: getData() works
const payload = e.dataTransfer?.getData('bytedraft/tab') ?? '';
const sourceWindow = e.dataTransfer?.getData('bytedraft/source-window') ?? '';
console.log('[DnD] window.drop same-renderer: source=', sourceWindow, 'payload.len=', payload.length);
if (sourceWindow === windowLabel && payload) {
h5DroppedInSameWindowRef.current = true;
try {
const dropped = JSON.parse(payload) as { id: string };
const store = useEditorStore.getState();
const fromIndex = store.tabs.findIndex((t) => t.id === dropped.id);
if (fromIndex === -1) return;
const toIndex = insertAt ?? store.tabs.length - 1;
if (fromIndex !== toIndex) reorderTabs(fromIndex, toIndex);
} catch { /* malformed */ }
return;
}
}
// Cross-renderer: getData() returns '' — retrieve payload via IPC relay
const b = getDesktopBridge();
if (!b) { console.log('[DnD] window.drop: no bridge'); return; }
void b.invoke<{ payload: string; sourceWindow: string } | null>('get_drag_payload').then((drag) => {
console.log('[DnD] window.drop get_drag_payload:', drag);
if (!drag) return;
let tabId: string | undefined;
try {
const serialized = JSON.parse(drag.payload) as SerializedTab;
tabId = serialized.id;
ingestSerializedTab(serialized);
if (insertAt !== null) {
const store = useEditorStore.getState();
const idx = store.tabs.findIndex((t) => t.id === serialized.id);
if (idx !== -1 && idx !== insertAt) store.reorderTabs(idx, insertAt);
}
} catch { /* malformed */ }
void b.invoke('notify_drop_accepted', { sourceWindow: drag.sourceWindow, tabId });
});
}
window.addEventListener('dragover', onDragOver);
window.addEventListener('drop', onDrop);
return () => {
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('drop', onDrop);
};
}, [reorderTabs, windowLabel]); // eslint-disable-line react-hooks/exhaustive-deps
// Wheel events in the title bar drag region are absorbed by Electron at the OS level.
// An explicit handler bypasses that and scrolls the container directly.
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.preventDefault();
tabStripRef.current?.scrollBy({ left: e.deltaX !== 0 ? e.deltaX : e.deltaY });
}, []);
return (
<div data-testid="tab-bar" className="hide-scrollbar flex-1 min-w-0 flex">
<div ref={tabStripRef} data-testid="tab-scroll-region" className="hide-scrollbar flex-1 min-w-0 overflow-x-auto overflow-y-hidden">
<div
ref={tabStripRef}
data-testid="tab-scroll-region"
onWheel={handleWheel}
className="hide-scrollbar flex-1 min-w-0 overflow-x-auto overflow-y-hidden"
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
autoScroll={false}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
<div className="flex items-stretch h-9 gap-0.5 w-max min-w-full">
{tabs.map((tab) => (
<SortableTab
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
onActivate={() => { if (!suppressActivateRef.current) setActiveTab(tab.id); }}
onClose={() => handleClose(tab)}
onRequestContextMenu={(e, t) => { e.preventDefault(); e.stopPropagation(); setTabContextMenu({ x: e.clientX, y: e.clientY, tab: t }); }}
/>
{tabs.map((tab, index) => (
<Fragment key={tab.id}>
{dragOverIndex === index && <DropIndicator />}
<SortableTab
tab={tab}
isActive={tab.id === activeTabId}
onActivate={() => { if (!suppressActivateRef.current) setActiveTab(tab.id); }}
onClose={() => handleClose(tab)}
onRequestContextMenu={(e, t) => { e.preventDefault(); e.stopPropagation(); setTabContextMenu({ x: e.clientX, y: e.clientY, tab: t }); }}
onHtml5DragStart={handleHtml5DragStart}
onHtml5DragEnd={handleHtml5DragEnd}
/>
</Fragment>
))}
{dragOverIndex === tabs.length && <DropIndicator />}
</div>
</SortableContext>
</DndContext>
@@ -271,6 +591,7 @@ export function TabStrip() {
onCloseUnmodified={closeUnmodifiedTabs}
onCloseToLeft={closeTabsToLeft}
onCloseToRight={closeTabsToRight}
onMoveToMain={isChildWindow ? () => { void moveTabToWindow(tabContextMenu.tab.id, 'main'); } : undefined}
/>
)}
</div>
+6 -18
View File
@@ -136,6 +136,7 @@ export function TitleBar({ onMenuOpen }: TitleBarProps) {
return (
<div
data-testid="title-bar"
data-electron-drag-region
className="flex items-stretch h-9 shrink-0 select-none"
style={{
backgroundColor: isMac ? 'transparent' : 'var(--bg-surface)',
@@ -167,33 +168,20 @@ export function TitleBar({ onMenuOpen }: TitleBarProps) {
</svg>
</button>
{/* App title label — window drag region */}
{/* App title label — no-drag so right-click doesn't hit the native drag-region event path (Electron 42 BMP crash) */}
<div
data-electron-drag-region
data-testid="window-title-label"
data-electron-no-drag
className="shrink-0 px-2 text-xs tracking-wide text-[var(--text-muted)] flex items-center"
>
ByteDraft
</div>
{/* Left window drag region */}
<div
data-electron-drag-region
data-testid="window-drag-region-left"
className="shrink-0 w-8"
aria-hidden
/>
{/* Tab strip */}
{/* Tab strip — background is draggable via parent; interactive tab elements opt out via CSS */}
<TabStrip />
{/* Right drag region between tabs and controls */}
<div
data-electron-drag-region
data-testid="window-drag-region-right"
className="shrink-0 w-14"
aria-hidden
/>
{/* Spacer between tabs and right controls */}
<div className="min-w-[1rem]" data-electron-drag-region aria-hidden />
{/* Right controls */}
<div data-testid="tab-controls-right" className="flex items-center gap-0.5 px-2 shrink-0">
@@ -1,12 +1,11 @@
import { useUiStore } from '../../state/uiStore';
type LayoutMode = 'classic' | 'minimal' | 'pane';
type LayoutMode = 'classic' | 'minimal';
type AccentColor = 'blue' | 'amber' | 'green' | 'vi';
const LAYOUT_OPTIONS: { label: string; value: LayoutMode }[] = [
{ label: 'Classic', value: 'classic' },
{ label: 'Minimal', value: 'minimal' },
{ label: 'Pane', value: 'pane' },
];
const ACCENT_OPTIONS: { label: string; value: AccentColor; swatchClass: string }[] = [
+43
View File
@@ -0,0 +1,43 @@
/**
* Renderer ↔ main-process bridge. The Electron preload should assign `window.byteDraft`
* with this shape (see {@link ByteDraftDesktop}).
*/
export interface ByteDraftDesktop {
readonly windowLabel: string;
/** True only for the primary application window (session + menu bar home). */
readonly isMainWindow: boolean;
invoke<T = unknown>(cmd: string, args?: Record<string, unknown>): Promise<T>;
minimize(): Promise<void>;
toggleMaximize(): Promise<void>;
closeWindow(): Promise<void>;
/** Fire-and-forget to other windows via the main process. */
emit(channel: string, payload?: unknown): Promise<void>;
/** Subscribe to events forwarded from `ipcRenderer` (payload only). */
subscribe(channel: string, handler: (payload: unknown) => void): () => void;
}
declare global {
interface Window {
byteDraft?: ByteDraftDesktop;
}
}
let _testBridge: ByteDraftDesktop | null | undefined;
/** @internal Used by Vitest to inject a mock bridge; `undefined` restores normal `window.byteDraft` lookup. */
export function __setDesktopBridgeForTests(bridge: ByteDraftDesktop | null | undefined): void {
_testBridge = bridge;
}
export function getDesktopBridge(): ByteDraftDesktop | null {
if (_testBridge !== undefined) return _testBridge;
if (typeof window === 'undefined') return null;
return window.byteDraft ?? null;
}
export async function desktopInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const b = getDesktopBridge();
if (!b) throw new Error('Desktop bridge unavailable');
return b.invoke<T>(cmd, args);
}
+3 -2
View File
@@ -52,6 +52,7 @@ export async function openFileByPath(filePath: string): Promise<void> {
store.addTab({
path: filePath,
content: fileData.content,
savedContent: fileData.content,
encoding: fileData.encoding,
language,
viewKind: viewKindForOpenFile(filePath, language),
@@ -176,7 +177,7 @@ export function useFileSystem() {
try {
const path = await api.saveFileAs(contentToSave);
if (path) {
store.updateTab(tabId, { path, isDirty: false });
store.updateTab(tabId, { path, isDirty: false, savedContent: contentToSave });
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -191,7 +192,7 @@ export function useFileSystem() {
try {
const data = await api.revertFile(tab.path);
store.updateTab(tabId, { content: data.content, isDirty: false });
store.updateTab(tabId, { content: data.content, isDirty: false, savedContent: data.content });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await api.showMessageBox({ title: 'Error', message: `Revert failed: ${msg}`, kind: 'error' });
+2 -1
View File
@@ -61,6 +61,7 @@ html, body, #root {
/* Interactive elements inside drag regions must opt out so clicks register */
[data-electron-drag-region] button,
[data-electron-drag-region] a,
[data-electron-drag-region] input {
[data-electron-drag-region] input,
[data-electron-no-drag] {
-webkit-app-region: no-drag;
}
+2 -6
View File
@@ -96,11 +96,9 @@ describe('uiStore', () => {
});
describe('setLayoutMode', () => {
it('sets layoutMode to classic, minimal, or pane', () => {
it('sets layoutMode to classic or minimal', () => {
useUiStore.getState().setLayoutMode('minimal');
expect(useUiStore.getState().layoutMode).toBe('minimal');
useUiStore.getState().setLayoutMode('pane');
expect(useUiStore.getState().layoutMode).toBe('pane');
useUiStore.getState().setLayoutMode('classic');
expect(useUiStore.getState().layoutMode).toBe('classic');
});
@@ -123,11 +121,9 @@ describe('uiStore', () => {
expect(useUiStore.getState().layoutMode !== 'minimal').toBe(false);
});
it('sidebar should be visible in classic and pane modes', () => {
it('sidebar should be visible in classic mode', () => {
useUiStore.getState().setLayoutMode('classic');
expect(useUiStore.getState().layoutMode !== 'minimal').toBe(true);
useUiStore.getState().setLayoutMode('pane');
expect(useUiStore.getState().layoutMode !== 'minimal').toBe(true);
});
});
});
+10
View File
@@ -0,0 +1,10 @@
import type { DockviewApi } from 'dockview';
let _api: DockviewApi | null = null;
export const dockApiRegistry = {
set: (api: DockviewApi | null) => {
_api = api;
},
get: (): DockviewApi | null => _api,
};
+32 -4
View File
@@ -19,6 +19,10 @@ interface EditorState {
activeTabId: string | null;
addTab: (tab: Partial<Tab>) => string;
removeTab: (id: string) => void;
/** Remove a tab without a dirty-state guard — used when tearing off to another window. */
removeTabSilently: (id: string) => void;
/** Accept an incoming tab from another window; no-ops if id already exists. */
ingestTab: (tab: Tab) => void;
updateTab: (id: string, changes: Partial<Tab>) => void;
setActiveTab: (id: string) => void;
setTabContent: (id: string, content: string) => void;
@@ -62,6 +66,28 @@ export const useEditorStore = create<EditorState>((set, get) => ({
});
},
removeTabSilently: (id) => {
set((state) => {
const idx = state.tabs.findIndex((t) => t.id === id);
if (idx === -1) return state;
const newTabs = state.tabs.filter((t) => t.id !== id);
let newActive = state.activeTabId;
if (state.activeTabId === id) {
if (newTabs.length === 0) newActive = null;
else if (idx < newTabs.length) newActive = newTabs[idx]!.id;
else newActive = newTabs[newTabs.length - 1]!.id;
}
return { tabs: newTabs, activeTabId: newActive };
});
},
ingestTab: (tab) => {
set((state) => {
if (state.tabs.some((t) => t.id === tab.id)) return state;
return { tabs: [...state.tabs, tab], activeTabId: tab.id };
});
},
updateTab: (id, changes) => {
set((state) => ({
tabs: state.tabs.map((t) => (t.id === id ? { ...t, ...changes } : t)),
@@ -74,16 +100,18 @@ export const useEditorStore = create<EditorState>((set, get) => ({
setTabContent: (id, content) => {
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === id ? { ...t, content, isDirty: true } : t,
),
tabs: state.tabs.map((t) => {
if (t.id !== id) return t;
const isDirty = t.savedContent !== undefined ? content !== t.savedContent : true;
return { ...t, content, isDirty };
}),
}));
},
markTabClean: (id) => {
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === id ? { ...t, isDirty: false } : t,
t.id === id ? { ...t, isDirty: false, savedContent: t.content } : t,
),
}));
},
+36 -12
View File
@@ -1,26 +1,34 @@
import { create } from 'zustand';
import { api } from '../api';
import { SessionData } from '../types';
import { useEditorStore } from './editorStore';
import { useUiStore } from './uiStore';
import { getDesktopBridge } from '../desktop-bridge';
interface SessionState {
workspaceRoot: string | null;
themeName: string;
dockLayout: string | null;
setWorkspaceRoot: (path: string | null) => void;
setThemeName: (name: string) => void;
setDockLayout: (layout: string | null) => void;
}
export const useSessionStore = create<SessionState>((set) => ({
workspaceRoot: null,
themeName: 'Notepads Dark',
dockLayout: null,
setWorkspaceRoot: (path) => set({ workspaceRoot: path }),
setThemeName: (name) => set({ themeName: name }),
setDockLayout: (layout) => set({ dockLayout: layout }),
}));
export async function loadSession(): Promise<void> {
try {
const raw = await api.loadSession();
const bridge = getDesktopBridge();
let raw: string | null = null;
if (bridge) {
raw = await bridge.invoke<string | null>('load_session');
}
if (!raw) {
useEditorStore.getState().hydrateFromSession([], null);
return;
@@ -28,35 +36,51 @@ export async function loadSession(): Promise<void> {
const data: SessionData = JSON.parse(raw);
useSessionStore.getState().setWorkspaceRoot(data.workspaceRoot);
useSessionStore.getState().setThemeName(data.themeName);
if (typeof data.formatOnSave === 'boolean') {
useUiStore.getState().setFormatOnSave(data.formatOnSave);
}
if (typeof data.formatOnPaste === 'boolean') {
useUiStore.getState().setFormatOnPaste(data.formatOnPaste);
if (data.dockLayout !== undefined) {
useSessionStore.getState().setDockLayout(data.dockLayout ?? null);
}
const ui = useUiStore.getState();
if (typeof data.formatOnSave === 'boolean') ui.setFormatOnSave(data.formatOnSave);
if (typeof data.formatOnPaste === 'boolean') ui.setFormatOnPaste(data.formatOnPaste);
if (typeof data.wordWrap === 'boolean') ui.setWordWrap(data.wordWrap);
if (typeof data.showLineEndings === 'boolean') ui.setShowLineEndings(data.showLineEndings);
if (data.layoutMode) ui.setLayoutMode(data.layoutMode);
if (data.accentColor) ui.setAccentColor(data.accentColor);
useEditorStore.getState().hydrateFromSession(data.tabs, data.activeTabId);
} catch {
// API unavailable (browser dev) or no session yet — seed a blank tab
// Bridge unavailable (browser dev) or no session yet — seed a blank tab
useEditorStore.getState().hydrateFromSession([], null);
}
}
export async function saveSession(): Promise<void> {
const bridge = getDesktopBridge();
// Only the main window owns the session file
if (bridge && !bridge.isMainWindow) return;
try {
const { tabs, activeTabId } = useEditorStore.getState();
const { workspaceRoot, themeName } = useSessionStore.getState();
const { formatOnSave, formatOnPaste } = useUiStore.getState();
const { workspaceRoot, themeName, dockLayout } = useSessionStore.getState();
const { formatOnSave, formatOnPaste, wordWrap, showLineEndings, layoutMode, accentColor } =
useUiStore.getState();
const data: SessionData = {
version: 1,
tabs,
activeTabId,
workspaceRoot,
themeName,
dockLayout,
formatOnSave,
formatOnPaste,
wordWrap,
showLineEndings,
layoutMode,
accentColor,
};
await api.saveSession(JSON.stringify(data));
const json = JSON.stringify(data);
if (bridge) {
await bridge.invoke('save_session', { session: json });
}
} catch {
// Silently ignore — API unavailable in browser dev
// Silently ignore — bridge unavailable in browser dev
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ interface UiState {
showLineEndings: boolean;
formatOnSave: boolean;
formatOnPaste: boolean;
layoutMode: 'classic' | 'minimal' | 'pane';
layoutMode: 'classic' | 'minimal';
accentColor: 'blue' | 'amber' | 'green' | 'vi';
toggleSidebar: () => void;
toggleFindPanel: () => void;
+343
View File
@@ -0,0 +1,343 @@
/**
* windowBus — cross-window coordination for tab tear-off, settings sync, and
* open-file deduplication.
*
* Uses `window.byteDraft` from the Electron preload. In plain Vite/browser dev
* the bridge is absent, so calls no-op safely.
*/
import type { Tab } from '../types';
import { getDesktopBridge } from '../desktop-bridge';
import { editorViewRegistry } from './editorViewRegistry';
import { useEditorStore } from './editorStore';
import { useSessionStore } from './sessionStore';
import { useUiStore } from './uiStore';
// ── Cross-window open-file tracking ─────────────────────────────────────────
/** File paths open in each known other window. */
const _otherWindowFiles = new Map<string, Set<string>>();
/** Labels of windows we have already acknowledged (for "respond once" logic). */
const _acknowledgedWindows = new Set<string>();
/**
* Update the known file list for another window.
* Returns `true` the first time a given window label is seen, so callers can
* respond by re-broadcasting their own list to that new neighbour.
*/
export function updateOtherWindowFiles(senderLabel: string, paths: string[]): boolean {
const isNew = !_acknowledgedWindows.has(senderLabel);
_acknowledgedWindows.add(senderLabel);
_otherWindowFiles.set(senderLabel, new Set(paths));
return isNew;
}
/**
* If `filePath` is open in any other known window, returns that window's label.
* Returns `null` if the file is not open elsewhere (or if no sync has happened yet).
*/
export function findFileInOtherWindows(filePath: string): string | null {
for (const [label, paths] of _otherWindowFiles) {
if (paths.has(filePath)) return label;
}
return null;
}
/** Clears cross-window file tracking maps. For unit tests only. */
export function resetCrossWindowFileTrackingForTests(): void {
_otherWindowFiles.clear();
_acknowledgedWindows.clear();
}
/**
* Broadcast this window's currently open file paths to all other windows.
* No-ops without a desktop bridge.
*/
export async function broadcastOpenFiles(paths: string[]): Promise<void> {
const b = getDesktopBridge();
if (!b) return;
try {
await b.emit('files:sync', { label: b.windowLabel, paths });
} catch {
/* ignore */
}
}
/**
* Listen for other windows' file-list broadcasts.
* `onSync` receives the sender's label, path list, and whether this is the first
* time we've seen that window (so the caller can respond by re-broadcasting its own list).
* Returns an unlisten function.
*/
export async function listenForFilesSync(
onSync: (label: string, paths: string[], isNew: boolean) => void,
): Promise<() => void> {
const b = getDesktopBridge();
if (!b) return () => {};
try {
return b.subscribe('files:sync', (raw) => {
const payload = raw as { label: string; paths: string[] };
const { label, paths } = payload;
const isNew = updateOtherWindowFiles(label, paths);
onSync(label, paths, isNew);
});
} catch {
return () => {};
}
}
// ── Pre-warm pool ──────────────────────────────────────────────────────────
/** Label of the currently pre-warmed hidden window, or null if pool is empty. */
let _prewarmLabel: string | null = null;
/**
* Spawn a hidden child window at startup so it is already loaded when the user
* tears off a tab. Stores the label in `_prewarmLabel` for use by `tearOffTab`.
* No-ops without a desktop bridge.
*/
export async function prewarmChildWindow(): Promise<void> {
const b = getDesktopBridge();
if (!b) return;
try {
const label = `win-prewarm-${crypto.randomUUID().slice(0, 8)}`;
await b.invoke('prewarm_child_window', { label });
_prewarmLabel = label;
} catch {
/* ignore */
}
}
// ── Tab transfer & tear-off ─────────────────────────────────────────────────
export interface SerializedTab extends Tab {
scrollTop?: number;
selectionFrom?: number;
selectionTo?: number;
/** Workspace root to propagate to the receiving window (tear-off only). */
workspaceRoot?: string | null;
/** Current theme so the child window opens with the correct appearance. */
themeName?: string;
/** Current accent colour. */
accentColor?: string;
}
/** Capture current CodeMirror viewport/selection state before destroying the view. */
function captureEditorState(tabId: string): Pick<SerializedTab, 'scrollTop' | 'selectionFrom' | 'selectionTo'> {
const view = editorViewRegistry.get(tabId);
if (!view) return {};
const scrollEl = view.scrollDOM;
const sel = view.state.selection.main;
return {
scrollTop: scrollEl.scrollTop,
selectionFrom: sel.from,
selectionTo: sel.to,
};
}
/**
* Apply an incoming SerializedTab to this window's stores.
* Shared by listenForIncomingTab (IPC path) and TabStrip's HTML5 DnD drop handler.
*/
export function ingestSerializedTab(serialized: SerializedTab): void {
useEditorStore.getState().ingestTab(serialized);
if (serialized.workspaceRoot !== undefined) {
useSessionStore.getState().setWorkspaceRoot(serialized.workspaceRoot);
}
if (serialized.themeName) {
useSessionStore.getState().setThemeName(serialized.themeName);
}
if (serialized.accentColor) {
useUiStore.setState({ accentColor: serialized.accentColor as 'blue' | 'amber' | 'green' | 'vi' });
}
}
/**
* Serialize a tab (including live CodeMirror state) for HTML5 DnD dataTransfer.
* Used by TabStrip's dragstart handler so the full tab payload travels with the
* native drag across Electron windows without an IPC round-trip.
*/
export function serializeTabForTransfer(tabId: string): string | null {
const { tabs } = useEditorStore.getState();
const tab = tabs.find((t) => t.id === tabId);
if (!tab) return null;
const workspaceRoot = useSessionStore.getState().workspaceRoot;
const themeName = useSessionStore.getState().themeName;
const accentColor = useUiStore.getState().accentColor;
const serialized: SerializedTab = { ...tab, ...captureEditorState(tabId), workspaceRoot, themeName, accentColor };
return JSON.stringify(serialized);
}
/**
* Return the labels of all currently open windows.
* Returns an empty array in browser/dev mode.
*/
export async function getWindowLabels(): Promise<string[]> {
const b = getDesktopBridge();
if (!b) return [];
try {
return (await b.invoke<string[] | null>('get_window_labels')) ?? [];
} catch {
return [];
}
}
/**
* Pull this window's initialization payload from the main process.
* Called once by child windows on mount to retrieve their initial tab data.
* Returns null in browser/dev mode or if no payload is pending.
*/
export async function getWindowPayload(): Promise<string | null> {
const b = getDesktopBridge();
if (!b) return null;
try {
return (await b.invoke<string | null>('get_window_payload')) ?? null;
} catch {
return null;
}
}
/**
* Move a tab to an already-open window identified by `targetLabel`.
* Serializes the tab, sends `tab:hydrate` to the target, then removes
* the tab from this window. Closes this window if it becomes empty and
* is not the main window. `screenX`/`screenY` are unused for moves to an
* existing window (it's already visible) but kept for API symmetry with `tearOffTab`.
*/
export async function moveTabToWindow(tabId: string, targetLabel: string, _screenX?: number, _screenY?: number): Promise<void> {
const { tabs } = useEditorStore.getState();
const tab = tabs.find((t) => t.id === tabId);
if (!tab) return;
const serialized: SerializedTab = { ...tab, ...captureEditorState(tabId) };
// Remove immediately for instant visual feedback; IPC completes in the background.
useEditorStore.getState().removeTabSilently(tabId);
const b = getDesktopBridge();
if (!b) return;
try {
await b.invoke('send_tab_to_window', { label: targetLabel, payload: JSON.stringify(serialized) });
await maybeCloseWindow();
} catch {
/* IPC unavailable */
}
}
/**
* Close the current window if it is empty (no tabs) and is not the main window.
*/
export async function maybeCloseWindow(): Promise<void> {
const { tabs } = useEditorStore.getState();
if (tabs.length > 0) return;
const b = getDesktopBridge();
if (!b || b.isMainWindow) return;
try {
await b.closeWindow();
} catch {
/* ignore */
}
}
/**
* Tear a tab off the current window and open it in a new Electron `BrowserWindow`.
* Uses the pre-warmed hidden window if available (near-instant), falling back
* to spawning a new window. `screenX`/`screenY` position the new window near
* the cursor drop point. No-ops gracefully in browser/dev mode.
*/
export async function tearOffTab(tabId: string, screenX?: number, screenY?: number): Promise<void> {
const { tabs } = useEditorStore.getState();
const tab = tabs.find((t) => t.id === tabId);
if (!tab) return;
const workspaceRoot = useSessionStore.getState().workspaceRoot;
const themeName = useSessionStore.getState().themeName;
const accentColor = useUiStore.getState().accentColor;
const serialized: SerializedTab = { ...tab, ...captureEditorState(tabId), workspaceRoot, themeName, accentColor };
const payload = JSON.stringify(serialized);
void screenX;
void screenY;
const wx = window.screenX + 60;
const wy = window.screenY + 60;
useEditorStore.getState().removeTabSilently(tabId);
const b = getDesktopBridge();
if (!b) return;
const prewarm = _prewarmLabel;
if (prewarm) {
_prewarmLabel = null;
void prewarmChildWindow();
try {
await b.invoke('set_window_payload', { label: prewarm, payload });
await b.invoke('send_tab_to_window', { label: prewarm, payload });
await b.invoke('activate_prewarm_window', { label: prewarm, x: wx, y: wy });
} catch {
/* ignore */
}
return;
}
try {
const label = `win-${crypto.randomUUID().slice(0, 8)}`;
await b.invoke('spawn_child_window', { label, payload, x: wx, y: wy });
} catch {
/* ignore */
}
}
/**
* Called in child windows on startup: listens for the `tab:hydrate` event
* from the spawning window and adds the incoming tab to this window's store.
*/
export async function listenForIncomingTab(): Promise<() => void> {
const b = getDesktopBridge();
if (!b) return () => {};
try {
return b.subscribe('tab:hydrate', (raw) => {
try {
const serialized: SerializedTab =
typeof raw === 'string' ? (JSON.parse(raw) as SerializedTab) : (raw as SerializedTab);
ingestSerializedTab(serialized);
} catch {
/* malformed */
}
});
} catch {
return () => {};
}
}
/**
* Broadcast shared settings (theme, accentColor, etc.) to all other windows
* so they stay in sync when changed in one window.
*/
export async function broadcastSettings(payload: Record<string, unknown>): Promise<void> {
const b = getDesktopBridge();
if (!b) return;
try {
await b.emit('settings:sync', payload);
} catch {
/* ignore */
}
}
/**
* Listen for settings-sync events from other windows and apply them locally.
* Returns an unlisten function to be called on component unmount.
*/
export async function listenForSettingsSync(
onSync: (payload: Record<string, unknown>) => void,
): Promise<() => void> {
const b = getDesktopBridge();
if (!b) return () => {};
try {
return b.subscribe('settings:sync', (event) => {
onSync(event as Record<string, unknown>);
});
} catch {
return () => {};
}
}
+7
View File
@@ -13,6 +13,8 @@ export interface Tab {
language: string;
languageOverride: string | null;
isDirty: boolean;
/** Content at the last save/open point; used to detect undo-to-clean state. Not persisted to session. */
savedContent?: string;
viewKind: 'text' | 'markdown' | 'markdownPreviewOnly' | 'hex' | 'image';
lineEnding: 'LF' | 'CRLF' | 'CR';
cursorLine: number;
@@ -25,9 +27,14 @@ export interface SessionData {
activeTabId: string | null;
workspaceRoot: string | null;
themeName: string;
dockLayout?: string | null;
/** Persisted editor preferences (optional for older session files). */
formatOnSave?: boolean;
formatOnPaste?: boolean;
wordWrap?: boolean;
showLineEndings?: boolean;
layoutMode?: 'classic' | 'minimal';
accentColor?: 'blue' | 'amber' | 'green' | 'vi';
}
export interface DirEntry {
@@ -0,0 +1,86 @@
/**
* Integration test: DockContainer + Dockview + TabContextMenu
*
* Renders the real DockviewReact component (not mocked) to verify that the
* getTabContextMenuItems wiring correctly shows TabContextMenu when Dockview
* invokes it. This test catches the class of bug where DockTab-in-isolation
* tests pass but Dockview's own event interception prevents the menu from
* showing in production.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { __setDesktopBridgeForTests } from '../src/desktop-bridge';
import type { ByteDraftDesktop } from '../src/desktop-bridge';
import { DockContainer } from '../src/components/Dock/DockContainer';
import { useEditorStore } from '../src/state/editorStore';
const TAB = {
id: 'integ-tab-1',
path: '/tmp/integ.txt',
content: 'integration test',
encoding: 'utf-8' as const,
language: 'plaintext',
languageOverride: null,
isDirty: false,
viewKind: 'text' as const,
lineEnding: 'LF' as const,
cursorLine: 0,
cursorCol: 0,
};
function resetStore() {
useEditorStore.setState({ tabs: [TAB], activeTabId: 'integ-tab-1' });
}
describe('DockContainer — context menu integration', () => {
beforeEach(() => {
resetStore();
__setDesktopBridgeForTests({
windowLabel: 'main',
isMainWindow: true,
invoke: async (cmd: string) => {
if (cmd === 'get_window_labels') return ['main'];
return undefined;
},
minimize: vi.fn(),
toggleMaximize: vi.fn(),
closeWindow: vi.fn(),
emit: vi.fn(),
subscribe: () => () => {},
} as ByteDraftDesktop);
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('renders DockContainer without crashing', async () => {
render(<DockContainer />);
// DockviewReact mounts asynchronously — wait for tab-bar to appear
await waitFor(() => expect(screen.getByTestId('tab-bar')).toBeInTheDocument(), { timeout: 3000 });
});
it('renders TabContextMenu when setActiveContextMenu is triggered via document handler', async () => {
// The document-level capture listener uses document.elementsFromPoint to find
// DockTab elements (via data-dock-tab) even behind Dockview's DnD overlay.
// jsdom does not implement layout/elementsFromPoint, so we cannot replicate
// the full DOM event path here — that requires a real Tauri binary smoke test.
//
// What we CAN verify: if a data-dock-tab element exists in the DOM and we
// manually construct the scenario where our document handler finds it, the
// TabContextMenu renders correctly.
//
// This test validates that DockContainer renders the real Dockview tree without
// crashing and that the TabContextMenu component is reachable from DockContainer.
render(<DockContainer />);
await waitFor(() => expect(screen.getByTestId('tab-bar')).toBeInTheDocument(), { timeout: 3000 });
// DockTab should have rendered a data-dock-tab element for our tab
const tabEl = document.querySelector('[data-dock-tab="integ-tab-1"]');
expect(tabEl).not.toBeNull();
// No context menu should be visible yet
expect(screen.queryByTestId('tab-context-menu')).not.toBeInTheDocument();
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Tests for the tab context menu system.
*
* Architecture note (post Phase-1 refactor):
* TabContextMenu is no longer rendered inside DockTab. It lives in DockContainer
* and is triggered via Dockview's `getTabContextMenuItems` prop. This prevents
* Dockview's own document-level capture listener from intercepting the contextmenu
* event before our handler fires.
*
* Tests here verify TabContextMenu's own behaviour (standalone rendering) and that
* DockTab no longer triggers any document-level contextmenu listeners.
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { __setDesktopBridgeForTests } from '../src/desktop-bridge';
import type { ByteDraftDesktop } from '../src/desktop-bridge';
import { DockTab, TabContextMenu } from '../src/components/Dock/DockTab';
import { useEditorStore } from '../src/state/editorStore';
import type { IDockviewPanelHeaderProps } from 'dockview';
function makeMockApi(tabId: string) {
return {
isActive: true,
setActive: vi.fn(),
id: tabId,
} as unknown as IDockviewPanelHeaderProps<{ tabId: string }>['api'];
}
const TAB = {
id: 'tab-rc-test',
path: '/tmp/test.txt',
content: 'hello',
encoding: 'utf-8' as const,
language: 'plaintext',
languageOverride: null,
isDirty: false,
viewKind: 'text' as const,
lineEnding: 'LF' as const,
cursorLine: 0,
cursorCol: 0,
};
function resetStore() {
useEditorStore.setState({ tabs: [TAB], activeTabId: 'tab-rc-test' });
}
describe('TabContextMenu (standalone)', () => {
beforeEach(() => {
resetStore();
__setDesktopBridgeForTests({
windowLabel: 'main',
isMainWindow: true,
invoke: async (cmd: string) => {
if (cmd === 'get_window_labels') return ['main'];
return undefined;
},
minimize: vi.fn(),
toggleMaximize: vi.fn(),
closeWindow: vi.fn(),
emit: vi.fn(),
subscribe: () => () => {},
} as ByteDraftDesktop);
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('renders all standard close items', () => {
render(<TabContextMenu x={100} y={100} tabId="tab-rc-test" onClose={() => {}} />);
expect(screen.getByTestId('tab-context-menu')).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Close' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Close Other Tabs' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Close All Tabs' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move to New Window' })).toBeInTheDocument();
});
it('calls onClose when the background overlay is clicked', () => {
const onClose = vi.fn();
render(<TabContextMenu x={100} y={100} tabId="tab-rc-test" onClose={onClose} />);
const overlay = document.querySelector('[aria-hidden]') as HTMLElement;
fireEvent.click(overlay);
expect(onClose).toHaveBeenCalledOnce();
});
it('closes the tab and calls onClose when Close is clicked', () => {
const onClose = vi.fn();
render(<TabContextMenu x={100} y={100} tabId="tab-rc-test" onClose={onClose} />);
fireEvent.click(screen.getByRole('menuitem', { name: 'Close' }));
expect(useEditorStore.getState().tabs).toHaveLength(0);
expect(onClose).toHaveBeenCalledOnce();
});
it('returns null when tabId does not exist in the store', () => {
const { container } = render(<TabContextMenu x={0} y={0} tabId="nonexistent" onClose={() => {}} />);
expect(container.firstChild).toBeNull();
});
});
describe('DockTab', () => {
beforeEach(() => resetStore());
it('renders the tab button with the file name', () => {
const api = makeMockApi('tab-rc-test');
render(
<DockTab
api={api}
params={{ tabId: 'tab-rc-test' }}
{...({} as Omit<IDockviewPanelHeaderProps<{ tabId: string }>, 'api' | 'params'>)}
/>,
);
expect(screen.getByTestId('tab-btn-tab-rc-test')).toBeInTheDocument();
expect(screen.getByTestId('tab-btn-tab-rc-test')).toHaveTextContent('test.txt');
});
it('does NOT render a context menu on right-click (context menu is in DockContainer)', () => {
const api = makeMockApi('tab-rc-test');
render(
<DockTab
api={api}
params={{ tabId: 'tab-rc-test' }}
{...({} as Omit<IDockviewPanelHeaderProps<{ tabId: string }>, 'api' | 'params'>)}
/>,
);
// DockTab no longer handles contextmenu events directly — that responsibility
// was moved to DockContainer via getTabContextMenuItems to avoid Dockview
// overlay event interception.
const tabBtn = screen.getByTestId('tab-btn-tab-rc-test');
fireEvent.contextMenu(tabBtn);
expect(screen.queryByTestId('tab-context-menu')).not.toBeInTheDocument();
});
it('does NOT fire startDragOutTracking on right-click pointerdown', () => {
const addEventSpy = vi.spyOn(document, 'addEventListener');
const api = makeMockApi('tab-rc-test');
render(
<DockTab
api={api}
params={{ tabId: 'tab-rc-test' }}
{...({} as Omit<IDockviewPanelHeaderProps<{ tabId: string }>, 'api' | 'params'>)}
/>,
);
const tabBtn = screen.getByTestId('tab-btn-tab-rc-test');
fireEvent.pointerDown(tabBtn, { button: 2 });
const pointermoveCalls = addEventSpy.mock.calls.filter(([type]) => type === 'pointermove');
const pointerupCalls = addEventSpy.mock.calls.filter(([type]) => type === 'pointerup');
expect(pointermoveCalls.length).toBe(0);
expect(pointerupCalls.length).toBe(0);
addEventSpy.mockRestore();
});
});
+69
View File
@@ -0,0 +1,69 @@
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { HexView } from '../src/components/HexView/HexView';
import { api } from '../src/api';
import type { Tab } from '../src/types';
const PNG_BYTES = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
function makeTab(overrides: Partial<Tab> = {}): Tab {
return {
id: 'hex-test',
path: '/images/photo.png',
content: '',
encoding: 'UTF-8',
language: 'plaintext',
languageOverride: null,
isDirty: false,
viewKind: 'hex',
lineEnding: 'LF',
cursorLine: 1,
cursorCol: 1,
...overrides,
};
}
describe('HexView', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('shows hex rows for a binary file (not "(empty file)")', async () => {
vi.spyOn(api, 'readBinary').mockResolvedValue(PNG_BYTES);
await act(async () => {
render(<HexView tab={makeTab()} />);
});
expect(screen.queryByText('(empty file)')).toBeNull();
expect(screen.getByTestId('hex-view').textContent).toMatch(/89/i);
});
it('shows "(empty file)" when readBinary returns empty array', async () => {
vi.spyOn(api, 'readBinary').mockResolvedValue([]);
await act(async () => {
render(<HexView tab={makeTab()} />);
});
expect(screen.getByText('(empty file)')).toBeInTheDocument();
});
it('shows "(empty file)" for a tab with no path', async () => {
await act(async () => {
render(<HexView tab={makeTab({ path: null })} />);
});
expect(screen.getByText('(empty file)')).toBeInTheDocument();
});
it('does not call readBinary when path is null', async () => {
const spy = vi.spyOn(api, 'readBinary').mockResolvedValue([]);
await act(async () => {
render(<HexView tab={makeTab({ path: null })} />);
});
expect(spy).not.toHaveBeenCalled();
});
});
+22
View File
@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { Preferences } from '../src/components/Preferences/Preferences';
import { useUiStore } from '../src/state/uiStore';
describe('Preferences', () => {
beforeEach(() => {
useUiStore.setState({ preferencesOpen: true });
});
it('does not render a Pane layout option', () => {
render(<Preferences />);
expect(screen.queryByRole('radio', { name: /pane/i })).toBeNull();
});
it('renders only Classic and Minimal layout options', () => {
render(<Preferences />);
expect(screen.getByRole('radio', { name: /classic/i })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: /minimal/i })).toBeInTheDocument();
expect(screen.queryByRole('radio', { name: /pane/i })).toBeNull();
});
});
+154
View File
@@ -0,0 +1,154 @@
/**
* Tear-off regression tests.
*
* Root cause of regression: TabStrip switched from PointerSensor → MouseSensor (Bug 5 fix),
* but the tear-off detection still checked `activatorEvent instanceof PointerEvent`.
* MouseSensor fires `mousedown` (a MouseEvent), so the check always returned false and
* tear-off silently stopped working with no existing test to catch it.
*
* Fix: check `instanceof MouseEvent` (the parent class of PointerEvent, covering both sensors).
*
* NOTE: Full end-to-end tear-off flow (drag tab → new Electron window) requires Playwright
* and is tracked in e2e/specs/multi-window.electron.spec.ts. These unit tests verify the
* event-type invariant that caused the silent regression.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { TabStrip } from '../src/components/TitleBar/TabStrip';
import { useEditorStore } from '../src/state/editorStore';
import { __setDesktopBridgeForTests } from '../src/desktop-bridge';
import { api } from '../src/api';
import type { Tab } from '../src/types';
const makeTab = (id: string): Tab => ({
id,
path: `/fake/${id}.txt`,
content: '',
encoding: 'utf-8',
language: 'plaintext',
languageOverride: null,
isDirty: false,
viewKind: 'text',
lineEnding: 'LF',
cursorLine: 1,
cursorCol: 1,
});
// ── Event type regression guard ───────────────────────────────────────────────
// These tests document the exact invariant that was broken and verify the fix.
describe('tear-off: event type regression guard', () => {
it('MouseSensor activator (mousedown) is a MouseEvent — NOT a PointerEvent', () => {
const mousedown = new MouseEvent('mousedown', { clientY: 50, bubbles: true });
// The old buggy check failed here — instanceof PointerEvent is false for MouseSensor events
expect(mousedown instanceof PointerEvent).toBe(false);
// The fixed check succeeds
expect(mousedown instanceof MouseEvent).toBe(true);
});
it('PointerEvent is also a MouseEvent (instanceof MouseEvent is safe for both sensors)', () => {
const ptr = new PointerEvent('pointerdown', { clientY: 50, bubbles: true });
expect(ptr instanceof MouseEvent).toBe(true);
expect(ptr instanceof PointerEvent).toBe(true);
});
it('tear-off threshold constants make sense (36px title bar + 100px tear threshold = 136px)', () => {
// Regression guard: if these constants change, tear-off behavior changes too.
// Values from TabStrip.tsx: TITLE_BAR_HEIGHT = 36, TEAR_THRESHOLD = 100
const TITLE_BAR_HEIGHT = 36;
const TEAR_THRESHOLD = 100;
const tearLine = TITLE_BAR_HEIGHT + TEAR_THRESHOLD;
expect(tearLine).toBe(136);
});
});
// ── Drag simulation: right-click guard ───────────────────────────────────────
// Full dnd-kit drag simulation doesn't work in jsdom (MouseSensor internals require
// a real browser pointer model). The right-click guard CAN be tested because it relies
// on dnd-kit's MouseSensor activation constraint (button !== 0), which filters before
// any drag starts — no drag events are queued.
describe('tear-off: right-click must not trigger drag', () => {
beforeEach(() => {
useEditorStore.setState({ tabs: [], activeTabId: null });
vi.clearAllMocks();
});
it('right-clicking a tab does not call api.tearOffTab', () => {
const tearOffSpy = vi.spyOn(api, 'tearOffTab');
const tab = makeTab('rc');
useEditorStore.setState({ tabs: [tab], activeTabId: 'rc' });
render(<TabStrip />);
const btn = screen.getByTestId('tab-btn-rc');
// Right-click (button: 2) — MouseSensor only activates on button 0, so no drag fires
fireEvent.mouseDown(btn, { button: 2, clientY: 18 });
fireEvent.mouseMove(document, { clientY: 300 });
fireEvent.mouseUp(document, { clientY: 300 });
expect(tearOffSpy).not.toHaveBeenCalled();
});
});
// ── IPC wiring verification ───────────────────────────────────────────────────
describe('tear-off: api.tearOffTab is present and callable', () => {
it('api exposes tearOffTab as a function', () => {
expect(typeof api.tearOffTab).toBe('function');
});
it('api exposes onRestoreTornTab as a function', () => {
expect(typeof api.onRestoreTornTab).toBe('function');
});
});
// ── Scroll region drag region behavior ───────────────────────────────────────
// The scroll container is intentionally NOT data-electron-no-drag so that empty
// tab-bar space acts as a window drag target. Individual SortableTab elements
// carry data-electron-no-drag to keep tab interaction working.
describe('tab scroll region', () => {
beforeEach(() => { useEditorStore.setState({ tabs: [], activeTabId: null }); });
it('does not carry data-electron-no-drag (empty space should drag the window)', () => {
render(<TabStrip />);
const scrollRegion = screen.getByTestId('tab-scroll-region');
expect(scrollRegion).not.toHaveAttribute('data-electron-no-drag');
});
});
// ── "Move to Main Window" context menu item ───────────────────────────────────
const makeTab2 = (id: string): Tab => ({
id, path: `/fake/${id}.txt`, content: '', encoding: 'utf-8',
language: 'plaintext', languageOverride: null, isDirty: false,
viewKind: 'text', lineEnding: 'LF', cursorLine: 1, cursorCol: 1,
});
describe('re-dock: "Move to Main Window" context menu item', () => {
afterEach(() => { __setDesktopBridgeForTests(undefined); });
it('does not appear when running in the main window', () => {
__setDesktopBridgeForTests({ isMainWindow: true, subscribe: () => () => {} } as Parameters<typeof __setDesktopBridgeForTests>[0]);
const tab = makeTab2('main-win');
useEditorStore.setState({ tabs: [tab], activeTabId: 'main-win' });
render(<TabStrip />);
fireEvent.contextMenu(screen.getByTestId('tab-btn-main-win'));
expect(screen.queryByRole('menuitem', { name: /move to main window/i })).not.toBeInTheDocument();
});
it('appears when running in a child (torn-off) window', () => {
__setDesktopBridgeForTests({ isMainWindow: false, subscribe: () => () => {} } as Parameters<typeof __setDesktopBridgeForTests>[0]);
const tab = makeTab2('child-win');
useEditorStore.setState({ tabs: [tab], activeTabId: 'child-win' });
render(<TabStrip />);
fireEvent.contextMenu(screen.getByTestId('tab-btn-child-win'));
expect(screen.getByRole('menuitem', { name: /move to main window/i })).toBeInTheDocument();
});
});
+48 -2
View File
@@ -40,7 +40,7 @@ describe('TitleBar', () => {
expect(screen.getByTestId('tab-btn-b')).toBeInTheDocument();
});
it('shows dirty indicator (●) on unsaved tabs', () => {
it('shows unsaved dot indicator on dirty tabs', () => {
useEditorStore.setState({
tabs: [
{ id: 'dirty', path: '/foo/file.rs', content: 'x', language: 'rust', languageOverride: null, isDirty: true, viewKind: 'text', encoding: 'UTF-8', lineEnding: 'LF', cursorLine: 1, cursorCol: 1 },
@@ -48,7 +48,20 @@ describe('TitleBar', () => {
activeTabId: 'dirty',
});
render(<TitleBar onMenuOpen={() => {}} />);
expect(screen.getByTestId('tab-btn-dirty').textContent).toMatch(/●/);
const btn = screen.getByTestId('tab-btn-dirty');
expect(btn.querySelector('[aria-label="Unsaved"]')).toBeInTheDocument();
});
it('does not show dirty indicator on clean tabs', () => {
useEditorStore.setState({
tabs: [
{ id: 'clean', path: '/foo/file.rs', content: 'x', language: 'rust', languageOverride: null, isDirty: false, viewKind: 'text', encoding: 'UTF-8', lineEnding: 'LF', cursorLine: 1, cursorCol: 1 },
],
activeTabId: 'clean',
});
render(<TitleBar onMenuOpen={() => {}} />);
const btn = screen.getByTestId('tab-btn-clean');
expect(btn.querySelector('[aria-label="Unsaved"]')).not.toBeInTheDocument();
});
it('active tab has data-tab-active=true', () => {
@@ -70,4 +83,37 @@ describe('TitleBar', () => {
fireEvent.click(screen.getByRole('button', { name: /new tab/i }));
expect(useEditorStore.getState().tabs.length).toBe(initialCount + 1);
});
it('title bar root has data-electron-drag-region for full-width window dragging', () => {
render(<TitleBar onMenuOpen={() => {}} />);
const titleBar = screen.getByTestId('title-bar');
expect(titleBar).toHaveAttribute('data-electron-drag-region');
});
it('ByteDraft label has data-electron-no-drag to prevent right-click crash on Electron 42 drag regions', () => {
render(<TitleBar onMenuOpen={() => {}} />);
const label = screen.getByTestId('window-title-label');
expect(label).toHaveAttribute('data-electron-no-drag');
expect(label).not.toHaveAttribute('data-electron-drag-region');
});
it('interactive buttons within title bar do NOT have data-electron-drag-region', () => {
render(<TitleBar onMenuOpen={() => {}} />);
const menuBtn = screen.getByTestId('menu-btn');
expect(menuBtn).not.toHaveAttribute('data-electron-drag-region');
const newTabBtn = screen.getByRole('button', { name: /new tab/i });
expect(newTabBtn).not.toHaveAttribute('data-electron-drag-region');
});
it('right-clicking a tab triggers context menu without crashing', () => {
useEditorStore.setState({
tabs: [
{ id: 'ctx', path: '/foo/file.ts', content: '', language: 'typescript', languageOverride: null, isDirty: false, viewKind: 'text', encoding: 'UTF-8', lineEnding: 'LF', cursorLine: 1, cursorCol: 1 },
],
activeTabId: 'ctx',
});
render(<TitleBar onMenuOpen={() => {}} />);
const tabBtn = screen.getByTestId('tab-btn-ctx');
expect(() => fireEvent.contextMenu(tabBtn)).not.toThrow();
});
});
+152
View File
@@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useEditorStore } from '../src/state/editorStore';
import type { Tab } from '../src/types';
const makeTab = (id: string, overrides: Partial<Tab> = {}): Tab => ({
id,
path: `/fake/${id}.txt`,
content: `content of ${id}`,
encoding: 'utf-8',
language: 'plaintext',
languageOverride: null,
isDirty: false,
viewKind: 'text',
lineEnding: 'LF',
cursorLine: 1,
cursorCol: 1,
...overrides,
});
describe('editorStore — removeTabSilently', () => {
beforeEach(() => {
useEditorStore.setState({ tabs: [], activeTabId: null });
});
it('removes a tab without returning early for dirty state', () => {
const tab = makeTab('a', { isDirty: true });
useEditorStore.setState({ tabs: [tab], activeTabId: 'a' });
useEditorStore.getState().removeTabSilently('a');
expect(useEditorStore.getState().tabs).toHaveLength(0);
expect(useEditorStore.getState().activeTabId).toBeNull();
});
it('activates the next tab after removal', () => {
const tabs = [makeTab('a'), makeTab('b'), makeTab('c')];
useEditorStore.setState({ tabs, activeTabId: 'b' });
useEditorStore.getState().removeTabSilently('b');
const state = useEditorStore.getState();
expect(state.tabs.map((t) => t.id)).toEqual(['a', 'c']);
expect(state.activeTabId).toBe('c');
});
it('no-ops for non-existent id', () => {
const tab = makeTab('a');
useEditorStore.setState({ tabs: [tab], activeTabId: 'a' });
useEditorStore.getState().removeTabSilently('does-not-exist');
expect(useEditorStore.getState().tabs).toHaveLength(1);
});
});
describe('editorStore — setTabContent with savedContent (undo-to-clean)', () => {
beforeEach(() => {
useEditorStore.setState({ tabs: [], activeTabId: null });
});
it('remains dirty when no savedContent is set', () => {
const tab = makeTab('a', { content: 'original', savedContent: undefined });
useEditorStore.setState({ tabs: [tab], activeTabId: 'a' });
useEditorStore.getState().setTabContent('a', 'changed');
expect(useEditorStore.getState().tabs[0].isDirty).toBe(true);
});
it('becomes dirty when content differs from savedContent', () => {
const tab = makeTab('a', { content: 'original', savedContent: 'original' });
useEditorStore.setState({ tabs: [tab], activeTabId: 'a' });
useEditorStore.getState().setTabContent('a', 'changed');
expect(useEditorStore.getState().tabs[0].isDirty).toBe(true);
});
it('clears dirty flag when content is undone back to savedContent', () => {
const tab = makeTab('a', { content: 'changed', savedContent: 'original', isDirty: true });
useEditorStore.setState({ tabs: [tab], activeTabId: 'a' });
// Simulate undo returning to original content
useEditorStore.getState().setTabContent('a', 'original');
expect(useEditorStore.getState().tabs[0].isDirty).toBe(false);
});
it('markTabClean captures current content as savedContent', () => {
const tab = makeTab('a', { content: 'saved content', isDirty: true });
useEditorStore.setState({ tabs: [tab], activeTabId: 'a' });
useEditorStore.getState().markTabClean('a');
const stored = useEditorStore.getState().tabs[0];
expect(stored.isDirty).toBe(false);
expect(stored.savedContent).toBe('saved content');
});
it('undo-to-clean works after markTabClean establishes save point', () => {
const tab = makeTab('a', { content: 'v1', savedContent: 'v1' });
useEditorStore.setState({ tabs: [tab], activeTabId: 'a' });
// Edit, then save (establishes v2 as clean)
useEditorStore.getState().setTabContent('a', 'v2');
expect(useEditorStore.getState().tabs[0].isDirty).toBe(true);
useEditorStore.getState().markTabClean('a');
expect(useEditorStore.getState().tabs[0].savedContent).toBe('v2');
// Edit again
useEditorStore.getState().setTabContent('a', 'v3');
expect(useEditorStore.getState().tabs[0].isDirty).toBe(true);
// Undo back to v2 (the save point)
useEditorStore.getState().setTabContent('a', 'v2');
expect(useEditorStore.getState().tabs[0].isDirty).toBe(false);
});
});
describe('editorStore — ingestTab', () => {
beforeEach(() => {
useEditorStore.setState({ tabs: [], activeTabId: null });
});
it('adds a tab from another window and activates it', () => {
const tab = makeTab('imported');
useEditorStore.getState().ingestTab(tab);
const state = useEditorStore.getState();
expect(state.tabs).toHaveLength(1);
expect(state.tabs[0].id).toBe('imported');
expect(state.activeTabId).toBe('imported');
});
it('does not duplicate if tab id already exists', () => {
const tab = makeTab('dup');
useEditorStore.setState({ tabs: [tab], activeTabId: 'dup' });
useEditorStore.getState().ingestTab(tab);
expect(useEditorStore.getState().tabs).toHaveLength(1);
});
it('preserves content and dirty flag of incoming tab', () => {
const tab = makeTab('x', { content: 'hello world', isDirty: true });
useEditorStore.getState().ingestTab(tab);
const stored = useEditorStore.getState().tabs[0];
expect(stored.content).toBe('hello world');
expect(stored.isDirty).toBe(true);
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* Verifies that only the main window writes session data to disk.
* Child windows must not overwrite the main session file.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { __setDesktopBridgeForTests } from '../src/desktop-bridge';
import type { ByteDraftDesktop } from '../src/desktop-bridge';
import { saveSession } from '../src/state/sessionStore';
import { useEditorStore } from '../src/state/editorStore';
import { useSessionStore } from '../src/state/sessionStore';
function stubBridge(overrides: Record<string, unknown> = {}): ByteDraftDesktop {
return {
windowLabel: 'main',
isMainWindow: true,
invoke: vi.fn(),
minimize: vi.fn(),
toggleMaximize: vi.fn(),
closeWindow: vi.fn(),
emit: vi.fn(),
subscribe: () => () => {},
...overrides,
} as unknown as ByteDraftDesktop;
}
function resetStores() {
useEditorStore.setState({ tabs: [], activeTabId: null });
useSessionStore.setState({ workspaceRoot: null, themeName: 'Notepads Dark', dockLayout: null });
}
describe('saveSession multi-window guard', () => {
beforeEach(() => {
resetStores();
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('calls save_session when invoked (main window context)', async () => {
const saveCalls: unknown[] = [];
const invoke = vi.fn(async (cmd: string, args: unknown) => {
if (cmd === 'save_session') saveCalls.push(args);
});
__setDesktopBridgeForTests(stubBridge({ invoke, windowLabel: 'main', isMainWindow: true }));
await saveSession();
expect(saveCalls.length).toBe(1);
});
it('saveSession serializes workspaceRoot in the payload', async () => {
useSessionStore.setState({ workspaceRoot: '/projects/myapp', themeName: 'One Dark', dockLayout: null });
let capturedPayload: string | undefined;
const invoke = vi.fn(async (cmd: string, args: unknown) => {
if (cmd === 'save_session') capturedPayload = (args as { session: string }).session;
});
__setDesktopBridgeForTests(stubBridge({ invoke }));
await saveSession();
expect(capturedPayload).toBeDefined();
const parsed = JSON.parse(capturedPayload!);
expect(parsed.workspaceRoot).toBe('/projects/myapp');
});
it('does not crash when invoke throws', async () => {
const invoke = vi.fn().mockRejectedValue(new Error('IPC unavailable'));
__setDesktopBridgeForTests(stubBridge({ invoke }));
await expect(saveSession()).resolves.toBeUndefined();
});
it('does not call save_session from a child window', async () => {
const saveCalls: unknown[] = [];
const invoke = vi.fn(async (cmd: string, args: unknown) => {
if (cmd === 'save_session') saveCalls.push(args);
});
__setDesktopBridgeForTests(
stubBridge({
invoke,
windowLabel: 'win-child-1',
isMainWindow: false,
}),
);
await saveSession();
expect(saveCalls).toHaveLength(0);
});
});
+161 -9
View File
@@ -1,19 +1,42 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useSessionStore } from '../src/state/sessionStore';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useSessionStore, loadSession, saveSession } from '../src/state/sessionStore';
import { useUiStore } from '../src/state/uiStore';
import { __setDesktopBridgeForTests } from '../src/desktop-bridge';
import type { ByteDraftDesktop } from '../src/desktop-bridge';
function stubBridge(overrides: Record<string, unknown> = {}): ByteDraftDesktop {
return {
windowLabel: 'main',
isMainWindow: true,
invoke: vi.fn().mockResolvedValue(undefined),
minimize: vi.fn(),
toggleMaximize: vi.fn(),
closeWindow: vi.fn(),
emit: vi.fn(),
subscribe: () => () => {},
...overrides,
} as unknown as ByteDraftDesktop;
}
function resetStores() {
useSessionStore.setState({ workspaceRoot: null, themeName: 'Notepads Dark', dockLayout: null });
useUiStore.setState({ wordWrap: false, showLineEndings: false, layoutMode: 'classic', accentColor: 'blue', formatOnSave: false, formatOnPaste: false });
}
describe('sessionStore', () => {
beforeEach(() => {
// Reset store before each test
useSessionStore.setState({
workspaceRoot: null,
themeName: 'dark',
});
resetStores();
__setDesktopBridgeForTests(undefined);
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('initializes with default state', () => {
const { workspaceRoot, themeName } = useSessionStore.getState();
expect(workspaceRoot).toBeNull();
expect(themeName).toBe('dark');
expect(themeName).toBe('Notepads Dark');
});
it('sets workspace root', () => {
@@ -36,8 +59,137 @@ describe('sessionStore', () => {
it('sets theme name', () => {
const { setThemeName } = useSessionStore.getState();
setThemeName('light');
const { themeName } = useSessionStore.getState();
expect(themeName).toBe('light');
});
describe('saveSession — full settings persistence', () => {
it('saves themeName in the session payload', async () => {
useSessionStore.setState({ themeName: 'Solarized Light' });
let payload: unknown;
const invoke = vi.fn(async (cmd: string, args: unknown) => {
if (cmd === 'save_session') payload = (args as { session: string }).session;
});
__setDesktopBridgeForTests(stubBridge({ invoke }));
await saveSession();
const data = JSON.parse(payload as string);
expect(data.themeName).toBe('Solarized Light');
});
it('saves wordWrap in the session payload', async () => {
useUiStore.getState().setWordWrap(true);
let payload: unknown;
const invoke = vi.fn(async (cmd: string, args: unknown) => {
if (cmd === 'save_session') payload = (args as { session: string }).session;
});
__setDesktopBridgeForTests(stubBridge({ invoke }));
await saveSession();
const data = JSON.parse(payload as string);
expect(data.wordWrap).toBe(true);
});
it('saves showLineEndings in the session payload', async () => {
useUiStore.getState().setShowLineEndings(true);
let payload: unknown;
const invoke = vi.fn(async (cmd: string, args: unknown) => {
if (cmd === 'save_session') payload = (args as { session: string }).session;
});
__setDesktopBridgeForTests(stubBridge({ invoke }));
await saveSession();
const data = JSON.parse(payload as string);
expect(data.showLineEndings).toBe(true);
});
it('saves layoutMode in the session payload', async () => {
useUiStore.getState().setLayoutMode('minimal');
let payload: unknown;
const invoke = vi.fn(async (cmd: string, args: unknown) => {
if (cmd === 'save_session') payload = (args as { session: string }).session;
});
__setDesktopBridgeForTests(stubBridge({ invoke }));
await saveSession();
const data = JSON.parse(payload as string);
expect(data.layoutMode).toBe('minimal');
});
it('saves accentColor in the session payload', async () => {
useUiStore.getState().setAccentColor('amber');
let payload: unknown;
const invoke = vi.fn(async (cmd: string, args: unknown) => {
if (cmd === 'save_session') payload = (args as { session: string }).session;
});
__setDesktopBridgeForTests(stubBridge({ invoke }));
await saveSession();
const data = JSON.parse(payload as string);
expect(data.accentColor).toBe('amber');
});
});
describe('loadSession — full settings restore', () => {
it('restores themeName into sessionStore', async () => {
const sessionJson = JSON.stringify({
version: 1, tabs: [], activeTabId: null, workspaceRoot: null,
themeName: 'Monokai', formatOnSave: false, formatOnPaste: false,
wordWrap: false, showLineEndings: false, layoutMode: 'classic', accentColor: 'blue',
});
const invoke = vi.fn(async (cmd: string) => {
if (cmd === 'load_session') return sessionJson;
});
__setDesktopBridgeForTests(stubBridge({ invoke }));
await loadSession();
expect(useSessionStore.getState().themeName).toBe('Monokai');
});
it('restores wordWrap into uiStore', async () => {
const sessionJson = JSON.stringify({
version: 1, tabs: [], activeTabId: null, workspaceRoot: null,
themeName: 'Notepads Dark', formatOnSave: false, formatOnPaste: false,
wordWrap: true, showLineEndings: false, layoutMode: 'classic', accentColor: 'blue',
});
const invoke = vi.fn(async (cmd: string) => {
if (cmd === 'load_session') return sessionJson;
});
__setDesktopBridgeForTests(stubBridge({ invoke }));
await loadSession();
expect(useUiStore.getState().wordWrap).toBe(true);
});
it('restores layoutMode into uiStore', async () => {
const sessionJson = JSON.stringify({
version: 1, tabs: [], activeTabId: null, workspaceRoot: null,
themeName: 'Notepads Dark', formatOnSave: false, formatOnPaste: false,
wordWrap: false, showLineEndings: false, layoutMode: 'minimal', accentColor: 'blue',
});
const invoke = vi.fn(async (cmd: string) => {
if (cmd === 'load_session') return sessionJson;
});
__setDesktopBridgeForTests(stubBridge({ invoke }));
await loadSession();
expect(useUiStore.getState().layoutMode).toBe('minimal');
});
it('restores accentColor into uiStore', async () => {
const sessionJson = JSON.stringify({
version: 1, tabs: [], activeTabId: null, workspaceRoot: null,
themeName: 'Notepads Dark', formatOnSave: false, formatOnPaste: false,
wordWrap: false, showLineEndings: false, layoutMode: 'classic', accentColor: 'green',
});
const invoke = vi.fn(async (cmd: string) => {
if (cmd === 'load_session') return sessionJson;
});
__setDesktopBridgeForTests(stubBridge({ invoke }));
await loadSession();
expect(useUiStore.getState().accentColor).toBe('green');
});
});
});
+23
View File
@@ -1 +1,24 @@
import '@testing-library/jest-dom';
// jsdom doesn't implement PointerEvent; polyfill it so tests that check instanceof PointerEvent work.
if (typeof PointerEvent === 'undefined') {
class PointerEvent extends MouseEvent {
readonly pointerId: number;
readonly pointerType: string;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 0;
this.pointerType = init.pointerType ?? 'mouse';
}
}
Object.defineProperty(window, 'PointerEvent', { value: PointerEvent, writable: true });
}
// Dockview (and other layout libraries) require ResizeObserver, which jsdom doesn't provide.
if (typeof ResizeObserver === 'undefined') {
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
}
+14 -2
View File
@@ -64,11 +64,23 @@ describe('uiStore', () => {
it('sets tweaks panel state', () => {
const { setTweaksPanelOpen } = useUiStore.getState();
setTweaksPanelOpen(true);
expect(useUiStore.getState().tweaksPanelOpen).toBe(true);
setTweaksPanelOpen(false);
expect(useUiStore.getState().tweaksPanelOpen).toBe(false);
});
it('layoutMode only supports classic and minimal (pane removed)', () => {
const { setLayoutMode } = useUiStore.getState();
setLayoutMode('classic');
expect(useUiStore.getState().layoutMode).toBe('classic');
setLayoutMode('minimal');
expect(useUiStore.getState().layoutMode).toBe('minimal');
// 'pane' must not be a valid value — this is enforced at the type level
// @ts-expect-error pane is not a valid layoutMode
expect(() => setLayoutMode('pane')).not.toThrow(); // runtime won't throw, but TS blocks it
// After an invalid set, store should still hold whatever was last set
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Tests for getWindowPayload() — pull-model initialization for child windows.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { __setDesktopBridgeForTests } from '../src/desktop-bridge';
import type { ByteDraftDesktop } from '../src/desktop-bridge';
import { getWindowPayload } from '../src/state/windowBus';
import { useEditorStore } from '../src/state/editorStore';
import { useSessionStore } from '../src/state/sessionStore';
function resetStores() {
useEditorStore.setState({ tabs: [], activeTabId: null });
useSessionStore.setState({ workspaceRoot: null, themeName: 'Notepads Dark', dockLayout: null });
}
function stubBridge(overrides: Record<string, unknown> = {}): ByteDraftDesktop {
return {
windowLabel: 'win-test',
isMainWindow: false,
invoke: vi.fn(),
minimize: vi.fn(),
toggleMaximize: vi.fn(),
closeWindow: vi.fn(),
emit: vi.fn(),
subscribe: () => () => {},
...overrides,
} as unknown as ByteDraftDesktop;
}
describe('getWindowPayload', () => {
beforeEach(() => {
resetStores();
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('returns null when no payload is pending (invoke returns undefined)', async () => {
const invoke = vi.fn().mockResolvedValue(undefined);
__setDesktopBridgeForTests(stubBridge({ invoke }));
const result = await getWindowPayload();
expect(result).toBeNull();
});
it('returns the serialized payload string when main has one ready', async () => {
const tabPayload = JSON.stringify({
id: 'tab-abc',
path: '/tmp/hello.txt',
content: 'world',
encoding: 'utf-8',
language: 'plaintext',
languageOverride: null,
isDirty: false,
viewKind: 'text',
lineEnding: 'LF',
cursorLine: 0,
cursorCol: 0,
workspaceRoot: '/projects/myapp',
});
const invoke = vi.fn(async (cmd: string) => {
if (cmd === 'get_window_payload') return tabPayload;
return undefined;
});
__setDesktopBridgeForTests(stubBridge({ invoke }));
const result = await getWindowPayload();
expect(result).toBe(tabPayload);
});
it('returns null (does not throw) when invoke fails', async () => {
const invoke = vi.fn().mockRejectedValue(new Error('IPC unavailable'));
__setDesktopBridgeForTests(stubBridge({ invoke }));
const result = await getWindowPayload();
expect(result).toBeNull();
});
it('returns null when no desktop bridge', async () => {
__setDesktopBridgeForTests(null);
const result = await getWindowPayload();
expect(result).toBeNull();
});
});
describe('child window boot flow integration', () => {
beforeEach(() => {
resetStores();
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('ingestTab + setWorkspaceRoot are called with payload data', async () => {
const serialized = {
id: 'tab-child',
path: '/tmp/child.txt',
content: 'from parent',
encoding: 'utf-8',
language: 'plaintext',
languageOverride: null,
isDirty: false,
viewKind: 'text',
lineEnding: 'LF',
cursorLine: 0,
cursorCol: 0,
workspaceRoot: '/projects/child',
};
const invoke = vi.fn(async (cmd: string) => {
if (cmd === 'get_window_payload') return JSON.stringify(serialized);
return undefined;
});
__setDesktopBridgeForTests(stubBridge({ invoke }));
const raw = await getWindowPayload();
expect(raw).toBeTruthy();
const parsed = JSON.parse(raw!);
useEditorStore.getState().ingestTab(parsed);
if (parsed.workspaceRoot) {
useSessionStore.getState().setWorkspaceRoot(parsed.workspaceRoot as string);
}
const { tabs } = useEditorStore.getState();
expect(tabs.some((t) => t.id === 'tab-child')).toBe(true);
expect(useSessionStore.getState().workspaceRoot).toBe('/projects/child');
});
});
@@ -0,0 +1,81 @@
/**
* Tests for maybeCloseWindow() — child windows close when empty; main never does.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { __setDesktopBridgeForTests } from '../src/desktop-bridge';
import type { ByteDraftDesktop } from '../src/desktop-bridge';
import { maybeCloseWindow } from '../src/state/windowBus';
import { useEditorStore } from '../src/state/editorStore';
function resetStore() {
useEditorStore.setState({ tabs: [], activeTabId: null });
}
function stubBridge(overrides: Partial<ByteDraftDesktop> = {}): ByteDraftDesktop {
return {
windowLabel: 'main',
isMainWindow: true,
invoke: vi.fn(),
minimize: vi.fn(),
toggleMaximize: vi.fn(),
closeWindow: vi.fn().mockResolvedValue(undefined),
emit: vi.fn(),
subscribe: () => () => {},
...overrides,
} as ByteDraftDesktop;
}
describe('maybeCloseWindow', () => {
beforeEach(() => {
resetStore();
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('invokes closeWindow when it is a child window with no tabs', async () => {
const closeWindow = vi.fn().mockResolvedValue(undefined);
__setDesktopBridgeForTests(
stubBridge({ windowLabel: 'win-abc123', isMainWindow: false, closeWindow }),
);
await maybeCloseWindow();
expect(closeWindow).toHaveBeenCalled();
});
it('does NOT close for the main window even when empty', async () => {
const closeWindow = vi.fn().mockResolvedValue(undefined);
__setDesktopBridgeForTests(
stubBridge({ windowLabel: 'main', isMainWindow: true, closeWindow }),
);
await maybeCloseWindow();
expect(closeWindow).not.toHaveBeenCalled();
});
it('does NOT close a child window when it still has tabs', async () => {
const closeWindow = vi.fn().mockResolvedValue(undefined);
__setDesktopBridgeForTests(
stubBridge({ windowLabel: 'win-abc123', isMainWindow: false, closeWindow }),
);
useEditorStore.setState({
tabs: [
{
id: 'tab-1',
path: '/tmp/file.txt',
content: 'hello',
encoding: 'utf-8',
language: 'plaintext',
languageOverride: null,
isDirty: false,
viewKind: 'text',
lineEnding: 'LF',
cursorLine: 0,
cursorCol: 0,
},
],
activeTabId: 'tab-1',
});
await maybeCloseWindow();
expect(closeWindow).not.toHaveBeenCalled();
});
});
+280
View File
@@ -0,0 +1,280 @@
/**
* windowBus unit tests — desktop bridge is mocked so these run in jsdom.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { __setDesktopBridgeForTests } from '../src/desktop-bridge';
import type { ByteDraftDesktop } from '../src/desktop-bridge';
import { useEditorStore } from '../src/state/editorStore';
import type { Tab } from '../src/types';
const makeTab = (id: string, overrides: Partial<Tab> = {}): Tab => ({
id,
path: `/fake/${id}.txt`,
content: `content of ${id}`,
encoding: 'utf-8',
language: 'plaintext',
languageOverride: null,
isDirty: false,
viewKind: 'text',
lineEnding: 'LF',
cursorLine: 1,
cursorCol: 1,
...overrides,
});
const mockInvoke = vi.fn();
const mockEmit = vi.fn();
const mockSubscribe = vi.fn();
const mockCloseWindow = vi.fn();
function installDefaultBridge(overrides: Partial<ByteDraftDesktop> = {}) {
__setDesktopBridgeForTests({
windowLabel: 'main',
isMainWindow: true,
invoke: mockInvoke,
minimize: vi.fn(),
toggleMaximize: vi.fn(),
closeWindow: mockCloseWindow,
emit: mockEmit,
subscribe: mockSubscribe,
...overrides,
} as ByteDraftDesktop);
}
describe('windowBus — tearOffTab', () => {
beforeEach(() => {
useEditorStore.setState({ tabs: [], activeTabId: null });
mockInvoke.mockReset();
installDefaultBridge();
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('invokes spawn_child_window with JSON-serialized tab', async () => {
const tab = makeTab('t1');
useEditorStore.setState({ tabs: [tab], activeTabId: 't1' });
mockInvoke.mockResolvedValue(undefined);
const { tearOffTab } = await import('../src/state/windowBus');
await tearOffTab('t1');
expect(mockInvoke).toHaveBeenCalledWith(
'spawn_child_window',
expect.objectContaining({ payload: expect.stringContaining('"id":"t1"') }),
);
});
it('removes the tab from the store after tear-off', async () => {
const tab = makeTab('t2');
useEditorStore.setState({ tabs: [tab], activeTabId: 't2' });
mockInvoke.mockResolvedValue(undefined);
const { tearOffTab } = await import('../src/state/windowBus');
await tearOffTab('t2');
expect(useEditorStore.getState().tabs).toHaveLength(0);
});
it('no-ops if the tab does not exist', async () => {
useEditorStore.setState({ tabs: [], activeTabId: null });
const { tearOffTab } = await import('../src/state/windowBus');
await tearOffTab('ghost');
expect(mockInvoke).not.toHaveBeenCalled();
});
it('removes the tab optimistically even if IPC throws', async () => {
const tab = makeTab('t3');
useEditorStore.setState({ tabs: [tab], activeTabId: 't3' });
mockInvoke.mockRejectedValue(new Error('IPC unavailable'));
const { tearOffTab } = await import('../src/state/windowBus');
await tearOffTab('t3');
expect(useEditorStore.getState().tabs).toHaveLength(0);
});
});
describe('windowBus — moveTabToWindow', () => {
beforeEach(() => {
useEditorStore.setState({ tabs: [], activeTabId: null });
mockInvoke.mockReset();
installDefaultBridge();
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('invokes send_tab_to_window with target label and payload', async () => {
const tab = makeTab('m1');
useEditorStore.setState({ tabs: [tab], activeTabId: 'm1' });
mockInvoke.mockResolvedValue(undefined);
const { moveTabToWindow } = await import('../src/state/windowBus');
await moveTabToWindow('m1', 'win-abc');
expect(mockInvoke).toHaveBeenCalledWith(
'send_tab_to_window',
expect.objectContaining({ label: 'win-abc', payload: expect.stringContaining('"id":"m1"') }),
);
});
it('removes the tab from the store after move', async () => {
const tab = makeTab('m2');
useEditorStore.setState({ tabs: [tab], activeTabId: 'm2' });
mockInvoke.mockResolvedValue(undefined);
const { moveTabToWindow } = await import('../src/state/windowBus');
await moveTabToWindow('m2', 'win-abc');
expect(useEditorStore.getState().tabs).toHaveLength(0);
});
it('no-ops if the tab does not exist', async () => {
useEditorStore.setState({ tabs: [], activeTabId: null });
const { moveTabToWindow } = await import('../src/state/windowBus');
await moveTabToWindow('ghost', 'win-abc');
expect(mockInvoke).not.toHaveBeenCalled();
});
});
describe('windowBus — getWindowLabels', () => {
beforeEach(() => {
mockInvoke.mockReset();
installDefaultBridge();
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('returns labels from get_window_labels', async () => {
mockInvoke.mockResolvedValue(['main', 'win-abc']);
const { getWindowLabels } = await import('../src/state/windowBus');
const labels = await getWindowLabels();
expect(labels).toEqual(['main', 'win-abc']);
expect(mockInvoke).toHaveBeenCalledWith('get_window_labels');
});
it('returns empty array if invoke throws', async () => {
mockInvoke.mockRejectedValue(new Error('no bridge'));
const { getWindowLabels } = await import('../src/state/windowBus');
const labels = await getWindowLabels();
expect(labels).toEqual([]);
});
});
describe('windowBus — listenForIncomingTab', () => {
let hydrateHandler: ((payload: unknown) => void) | undefined;
beforeEach(() => {
useEditorStore.setState({ tabs: [], activeTabId: null });
mockSubscribe.mockReset();
hydrateHandler = undefined;
mockSubscribe.mockImplementation((channel: string, cb: (payload: unknown) => void) => {
if (channel === 'tab:hydrate') hydrateHandler = cb;
return () => {};
});
installDefaultBridge();
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('registers a tab:hydrate listener and calls ingestTab on event', async () => {
const tab = makeTab('incoming');
const { listenForIncomingTab } = await import('../src/state/windowBus');
const unlisten = await listenForIncomingTab();
expect(mockSubscribe).toHaveBeenCalledWith('tab:hydrate', expect.any(Function));
hydrateHandler!(JSON.stringify(tab));
expect(useEditorStore.getState().tabs).toHaveLength(1);
expect(useEditorStore.getState().tabs[0].id).toBe('incoming');
expect(useEditorStore.getState().activeTabId).toBe('incoming');
expect(typeof unlisten).toBe('function');
});
it('ignores malformed JSON payloads', async () => {
const { listenForIncomingTab } = await import('../src/state/windowBus');
await listenForIncomingTab();
hydrateHandler!('not-valid-json');
expect(useEditorStore.getState().tabs).toHaveLength(0);
});
});
describe('windowBus — cross-window file tracking', () => {
beforeEach(async () => {
const { resetCrossWindowFileTrackingForTests } = await import('../src/state/windowBus');
resetCrossWindowFileTrackingForTests();
mockSubscribe.mockReset();
installDefaultBridge();
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('updateOtherWindowFiles returns true (isNew) the first time a label is seen', async () => {
const { updateOtherWindowFiles } = await import('../src/state/windowBus');
const isNew = updateOtherWindowFiles('win-first', ['/a/file.txt']);
expect(isNew).toBe(true);
});
it('updateOtherWindowFiles returns false on subsequent calls for the same label', async () => {
const { updateOtherWindowFiles } = await import('../src/state/windowBus');
updateOtherWindowFiles('win-repeat', ['/a.txt']);
const isNew = updateOtherWindowFiles('win-repeat', ['/b.txt']);
expect(isNew).toBe(false);
});
it('findFileInOtherWindows returns the window label when the file is tracked', async () => {
const { updateOtherWindowFiles, findFileInOtherWindows } = await import('../src/state/windowBus');
updateOtherWindowFiles('win-alpha', ['/docs/notes.txt', '/docs/todo.md']);
expect(findFileInOtherWindows('/docs/notes.txt')).toBe('win-alpha');
});
it('findFileInOtherWindows returns null for an untracked file', async () => {
const { findFileInOtherWindows } = await import('../src/state/windowBus');
expect(findFileInOtherWindows('/not/tracked.rs')).toBeNull();
});
it('findFileInOtherWindows returns null after a window updates with an empty list', async () => {
const { updateOtherWindowFiles, findFileInOtherWindows } = await import('../src/state/windowBus');
updateOtherWindowFiles('win-beta', ['/gone.txt']);
expect(findFileInOtherWindows('/gone.txt')).toBe('win-beta');
updateOtherWindowFiles('win-beta', []);
expect(findFileInOtherWindows('/gone.txt')).toBeNull();
});
it('listenForFilesSync fires onSync with the sender label, paths, and isNew flag', async () => {
let filesSyncHandler: ((payload: unknown) => void) | undefined;
mockSubscribe.mockImplementation((channel: string, cb: (payload: unknown) => void) => {
if (channel === 'files:sync') filesSyncHandler = cb;
return () => {};
});
const { listenForFilesSync } = await import('../src/state/windowBus');
const onSync = vi.fn();
await listenForFilesSync(onSync);
filesSyncHandler!({ label: 'win-new', paths: ['/foo.txt'] });
expect(onSync).toHaveBeenCalledWith('win-new', ['/foo.txt'], expect.any(Boolean));
});
});