Tear-off and re-docking fixed
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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 }) => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Generated
+336
-4
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 */}
|
||||
|
||||
@@ -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 }) },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }[] = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user