mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ecfc2b350 | ||
|
|
cc8a2f1b76 | ||
|
|
726904c9b7 | ||
|
|
70dd036bca | ||
|
|
3ea88abbd9 | ||
|
|
4f7a68d58e | ||
|
|
014ecd9ac8 | ||
|
|
e1f0eb6b43 | ||
|
|
f0239bc444 | ||
|
|
86d2622a08 | ||
|
|
848086739d | ||
|
|
ff52f91f4c | ||
|
|
4837acf0d5 | ||
|
|
c57431ede7 |
No files matched your search
@@ -8,6 +8,7 @@ import {
|
||||
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
|
||||
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
|
||||
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
|
||||
import { focusEditorInWindow } from '../state/editorWindowClient';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
|
||||
@@ -71,6 +72,16 @@ export function AppActiveTabChrome({
|
||||
}
|
||||
}, [showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorTabId(activeTabId)) return;
|
||||
const editorId = fromEditorTabId(activeTabId);
|
||||
if (!editorId) return;
|
||||
const tab = editorTabs.find((entry) => entry.id === editorId);
|
||||
if (tab?.placement !== 'window') return;
|
||||
void focusEditorInWindow(editorId);
|
||||
setActiveTabId('vault');
|
||||
}, [activeTabId, editorTabs, setActiveTabId]);
|
||||
|
||||
const chromeThemeDeps = useMemo(() => ({
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
|
||||
import { useWindowControls } from '../state/useWindowControls';
|
||||
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
|
||||
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
|
||||
import { installEditorWindowSourceListeners } from '../state/editorWindowClient';
|
||||
import { findEditorSftpOwnerTabId } from '../state/editorSftpOwnerRegistry';
|
||||
import {
|
||||
isPluginViewTabId,
|
||||
@@ -636,6 +637,8 @@ export function AppSideEffects() {
|
||||
workspaces,
|
||||
});
|
||||
|
||||
useEffect(() => installEditorWindowSourceListeners(), []);
|
||||
|
||||
const pendingTrayPortForwardsWhileLockedRef = useRef<Array<{ ruleId: string; start: boolean }>>([]);
|
||||
|
||||
const _handleTrayTogglePortForwardMaybeDeferred = useEffectEvent((ruleId: string, start: boolean) => {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
import { editorTabStore, tabIsDirty } from '../state/editorTabStore';
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
|
||||
import { focusEditorInWindow, popOutEditorTab } from '../state/editorWindowClient';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { TopTabs } from '../../components/TopTabs';
|
||||
import { VaultView } from '../../components/VaultView';
|
||||
@@ -421,6 +422,23 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
|
||||
if (tab.placement === "window") {
|
||||
const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({ editorIds: [id] });
|
||||
const ok = Boolean(result?.success) && result?.cancelled !== true;
|
||||
if (ok) {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditorsRef.current;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() === closingTabId) {
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
const closeEditorAndActivateNeighbor = () => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditorsRef.current;
|
||||
@@ -432,7 +450,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
const dirty = tabIsDirty(tab);
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
@@ -463,6 +481,16 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
// App.tsx stub `() => false` between commit and useEffect.
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
const handlePopOutEditorTab = useCallback(async (id: string) => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab || tab.placement === "window") {
|
||||
if (tab) void focusEditorInWindow(id);
|
||||
return;
|
||||
}
|
||||
const host = hostById.get(tab.hostId);
|
||||
await popOutEditorTab(id, host?.label);
|
||||
}, [hostById]);
|
||||
|
||||
const handleSaveSessionRename = useCallback((name: string) => {
|
||||
if (!sessionRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
@@ -539,6 +567,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
pluginViewTabs={pluginViewTabs}
|
||||
onClosePluginViewTab={closePluginViewTab}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
onPopOutEditorTab={handlePopOutEditorTab}
|
||||
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
@@ -789,7 +819,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
))}
|
||||
|
||||
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
||||
{editorTabs.map((tab) => (
|
||||
{editorTabs.filter((tab) => tab.placement !== "window").map((tab) => (
|
||||
<LazyLoadBoundary key={tab.id} name="Editor" resetKey={tab.id}>
|
||||
<Suspense fallback={<TextEditorTabFallback tabId={tab.id} />}>
|
||||
<LazyTextEditorTabView
|
||||
@@ -798,6 +828,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
|
||||
@@ -29,7 +29,7 @@ test('every renderer root mounts under StrictMode', () => {
|
||||
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
|
||||
|
||||
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
|
||||
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
|
||||
assert.equal(renderCalls.length, 5, 'main, settings, tray, terminal-popup and editor-window roots');
|
||||
|
||||
let cursor = 0;
|
||||
for (let index = 0; index < renderCalls.length; index += 1) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
|
||||
import { editorTabStore } from "../state/editorTabStore.ts";
|
||||
import { popOutEditorTab } from "../state/editorWindowClient.ts";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
import { toast } from "../../components/ui/toast.tsx";
|
||||
|
||||
test("app Quit sees the source's unsaved contents until the detached receiver accepts them", async (t) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const editorId = "quit-during-editor-handoff";
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
editorTabStore.close(editorId);
|
||||
if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous);
|
||||
else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
let query!: () => Promise<void>;
|
||||
let accept!: (result: { success: boolean }) => void;
|
||||
const receipt = new Promise<{ success: boolean }>((resolve) => { accept = resolve; });
|
||||
const reports: boolean[] = [];
|
||||
t.mock.method(toast, "warning", () => {});
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
openEditorWindow: () => receipt,
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean) => reports.push(dirty),
|
||||
}));
|
||||
function SourceWindow() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
|
||||
return null;
|
||||
}
|
||||
await act(async () => { renderer = create(React.createElement(SourceWindow)); });
|
||||
editorTabStore.upsertFromSnapshot({ editorId, sessionId: "source", sftpTabId: "sftp", hostId: "fixture", remotePath: "/alpha.txt", fileName: "alpha.txt", languageId: "plaintext", content: "unsaved", baselineContent: "original", wordWrap: false, viewState: null });
|
||||
const opening = popOutEditorTab(editorId);
|
||||
assert.equal(editorTabStore.getTab(editorId)?.placement, "window");
|
||||
await query();
|
||||
assert.deepEqual(reports, [true], "a cold receiver must not make the only owned unsaved copy invisible to Quit");
|
||||
accept({ success: true });
|
||||
assert.equal(await opening, true);
|
||||
assert.equal(editorTabStore.getTab(editorId)?.content, "");
|
||||
await query();
|
||||
assert.deepEqual(reports, [true, false], "after receipt the destination owns dirty reporting; the source copy is released");
|
||||
});
|
||||
@@ -357,6 +357,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
try {
|
||||
// Pending handoffs retain source contents; accepted handoffs clear them.
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
if (!hasDirty) {
|
||||
|
||||
@@ -865,6 +865,13 @@ export const enTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
'sftp.editor.maximize': 'Maximize',
|
||||
'sftp.editor.popOut': 'Open in editor window',
|
||||
'sftp.editor.dock': 'Dock to main window',
|
||||
'sftp.editor.focusWindow': 'Focus editor window',
|
||||
'sftp.editor.close': 'Close',
|
||||
'sftp.editor.windowTitle': 'Editor',
|
||||
'sftp.editor.windowEmpty': 'No files open',
|
||||
'sftp.editor.dockFailed': 'Could not dock this file back to the main window',
|
||||
'sftp.editor.unsavedTitle': 'Unsaved changes',
|
||||
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
|
||||
'sftp.editor.discardChanges': 'Discard',
|
||||
|
||||
@@ -865,6 +865,13 @@ export const esTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Ajuste de línea',
|
||||
'sftp.editor.maximize': 'Maximizar',
|
||||
'sftp.editor.popOut': 'Abrir en ventana de editor',
|
||||
'sftp.editor.dock': 'Anclar a la ventana principal',
|
||||
'sftp.editor.focusWindow': 'Enfocar ventana de editor',
|
||||
'sftp.editor.close': 'Cerrar',
|
||||
'sftp.editor.windowTitle': 'Editor',
|
||||
'sftp.editor.windowEmpty': 'No hay archivos abiertos',
|
||||
'sftp.editor.dockFailed': 'No se pudo anclar este archivo a la ventana principal',
|
||||
'sftp.editor.unsavedTitle': 'Cambios sin guardar',
|
||||
'sftp.editor.unsavedMessage': '{fileName} tiene cambios sin guardar. ¿Guardar antes de cerrar?',
|
||||
'sftp.editor.discardChanges': 'Descartar',
|
||||
|
||||
@@ -872,6 +872,13 @@ export const ruTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Перенос строк',
|
||||
'sftp.editor.maximize': 'Развернуть',
|
||||
'sftp.editor.popOut': 'Открыть в окне редактора',
|
||||
'sftp.editor.dock': 'Вернуть в главное окно',
|
||||
'sftp.editor.focusWindow': 'Перейти к окну редактора',
|
||||
'sftp.editor.close': 'Закрыть',
|
||||
'sftp.editor.windowTitle': 'Редактор',
|
||||
'sftp.editor.windowEmpty': 'Нет открытых файлов',
|
||||
'sftp.editor.dockFailed': 'Не удалось вернуть файл в главное окно',
|
||||
'sftp.editor.unsavedTitle': 'Несохранённые изменения',
|
||||
'sftp.editor.unsavedMessage': 'В файле {fileName} есть несохранённые изменения. Сохранить перед закрытием?',
|
||||
'sftp.editor.discardChanges': 'Отбросить',
|
||||
|
||||
@@ -961,6 +961,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
'sftp.editor.maximize': '最大化',
|
||||
'sftp.editor.popOut': '在编辑器窗口打开',
|
||||
'sftp.editor.dock': '停靠回主窗口',
|
||||
'sftp.editor.focusWindow': '聚焦编辑器窗口',
|
||||
'sftp.editor.close': '关闭',
|
||||
'sftp.editor.windowTitle': '编辑器',
|
||||
'sftp.editor.windowEmpty': '没有打开的文件',
|
||||
'sftp.editor.dockFailed': '无法把该文件停靠回主窗口',
|
||||
'sftp.editor.unsavedTitle': '未保存的修改',
|
||||
'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?',
|
||||
'sftp.editor.discardChanges': '不保存',
|
||||
|
||||
@@ -961,6 +961,13 @@ export const zhTWTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自動換行',
|
||||
'sftp.editor.maximize': '最大化',
|
||||
'sftp.editor.popOut': '在編輯器視窗開啟',
|
||||
'sftp.editor.dock': '停靠回主視窗',
|
||||
'sftp.editor.focusWindow': '聚焦編輯器視窗',
|
||||
'sftp.editor.close': '關閉',
|
||||
'sftp.editor.windowTitle': '編輯器',
|
||||
'sftp.editor.windowEmpty': '沒有開啟的檔案',
|
||||
'sftp.editor.dockFailed': '無法把此檔案停靠回主視窗',
|
||||
'sftp.editor.unsavedTitle': '未儲存的修改',
|
||||
'sftp.editor.unsavedMessage': '{fileName} 有未儲存的修改,是否儲存後關閉?',
|
||||
'sftp.editor.discardChanges': '不儲存',
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { editorTabStore } from "../../application/state/editorTabStore.ts";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
import { toast } from "../../components/ui/toast.tsx";
|
||||
import { useSftpViewFileOps } from "../../components/sftp/hooks/useSftpViewFileOps.ts";
|
||||
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "../../components/sftp/hooks/useSftpViewFileOps.types.ts";
|
||||
|
||||
for (const action of ["onPopOut", "onPromoteToTab"] as const) {
|
||||
test(`${action} preserves a modified modal when the file is already detached`, async (t) => {
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let ops!: UseSftpViewFileOpsResult;
|
||||
const params = {
|
||||
sftpRef: { current: {
|
||||
leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } },
|
||||
readTextFile: async () => "original",
|
||||
} },
|
||||
behaviorRef: { current: "" }, autoSyncRef: { current: false },
|
||||
getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {},
|
||||
t: (key: string) => key,
|
||||
} as unknown as UseSftpViewFileOpsParams;
|
||||
const original = {
|
||||
editorId: "modal-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
|
||||
remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext",
|
||||
content: "window edits", baselineContent: "original", wordWrap: false, viewState: null,
|
||||
};
|
||||
editorTabStore.upsertFromSnapshot(original, "window");
|
||||
const before = editorTabStore.getTab(original.editorId);
|
||||
t.after(() => editorTabStore.close(original.editorId));
|
||||
let focusCount = 0;
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
focusEditorWindow: async () => { focusCount++; return { success: true }; },
|
||||
}));
|
||||
const errors = t.mock.method(toast, "error", () => {});
|
||||
function Harness() { ops = useSftpViewFileOps(params); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
||||
t.after(async () => {
|
||||
await act(async () => renderer.unmount());
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
});
|
||||
await act(async () => {
|
||||
await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters<typeof ops.onEditFileLeft>[0], "/./file.txt");
|
||||
});
|
||||
const modal = { ...original, content: "modal edits" };
|
||||
await act(async () => { ops[action](modal); });
|
||||
assert.equal(ops.showTextEditor, true);
|
||||
assert.equal(ops.textEditorTarget?.fullPath, "/./file.txt");
|
||||
assert.equal(ops.textEditorContent, "original");
|
||||
assert.equal(modal.content, "modal edits");
|
||||
assert.equal(editorTabStore.getTab(original.editorId), before);
|
||||
assert.equal(focusCount, 0);
|
||||
assert.equal(errors.mock.callCount(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const focusSucceeds of [true, false]) {
|
||||
test(`Maximize dismisses a clean duplicate modal only after detached focus succeeds: ${focusSucceeds}`, async (t) => {
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let ops!: UseSftpViewFileOpsResult;
|
||||
const params = {
|
||||
sftpRef: { current: {
|
||||
leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } },
|
||||
readTextFile: async () => "original",
|
||||
} },
|
||||
behaviorRef: { current: "" }, autoSyncRef: { current: false },
|
||||
getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {},
|
||||
t: (key: string) => key,
|
||||
} as unknown as UseSftpViewFileOpsParams;
|
||||
const detached = {
|
||||
editorId: "clean-duplicate", sessionId: "conn", sftpTabId: "pane", hostId: "host",
|
||||
remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext",
|
||||
content: "detached edits", baselineContent: "original", wordWrap: false, viewState: null,
|
||||
};
|
||||
editorTabStore.upsertFromSnapshot(detached, "window");
|
||||
const before = editorTabStore.getTab(detached.editorId);
|
||||
t.after(() => editorTabStore.close(detached.editorId));
|
||||
let finishFocus!: (result: { success: boolean }) => void;
|
||||
const focusing = new Promise<{ success: boolean }>((resolve) => { finishFocus = resolve; });
|
||||
let focusCount = 0;
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
focusEditorWindow: () => { focusCount++; return focusing; },
|
||||
}));
|
||||
function Harness() { ops = useSftpViewFileOps(params); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
||||
t.after(async () => {
|
||||
await act(async () => renderer.unmount());
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
});
|
||||
await act(async () => {
|
||||
await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters<typeof ops.onEditFileLeft>[0], "/file.txt");
|
||||
});
|
||||
await act(async () => { ops.onPromoteToTab({ ...detached, content: "original" }); });
|
||||
assert.equal(focusCount, 1);
|
||||
assert.equal(ops.showTextEditor, true, "keep the modal until focus is acknowledged");
|
||||
await act(async () => { finishFocus({ success: focusSucceeds }); });
|
||||
assert.equal(ops.showTextEditor, !focusSucceeds);
|
||||
assert.equal(ops.textEditorTarget?.fullPath ?? null, focusSucceeds ? null : "/file.txt");
|
||||
assert.equal(ops.textEditorContent, focusSucceeds ? "" : "original");
|
||||
assert.equal(editorTabStore.getTab(detached.editorId), before, "focusing must preserve existing detached edits");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
test("independent renderers creating editors in the same millisecond get distinct identities", async (t) => {
|
||||
t.mock.method(Date, "now", () => 1234567890);
|
||||
const [firstRenderer, secondRenderer] = await Promise.all([
|
||||
import(new URL("./editorTabStore.ts?renderer=first", import.meta.url).href),
|
||||
import(new URL("./editorTabStore.ts?renderer=second", import.meta.url).href),
|
||||
]);
|
||||
assert.notEqual(firstRenderer.EditorTabStore, secondRenderer.EditorTabStore, "use independent renderer module state");
|
||||
const snapshot = { sessionId: "session", sftpTabId: "pane", hostId: "host", remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext", content: "edits", baselineContent: "original", wordWrap: false, viewState: null };
|
||||
const first = new firstRenderer.EditorTabStore().promoteFromModal(snapshot);
|
||||
const second = new secondRenderer.EditorTabStore().promoteFromModal(snapshot);
|
||||
assert.notEqual(first, second, "process-wide ownership must not collide across renderer-local counters");
|
||||
assert.match(first, /^edt_[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i);
|
||||
});
|
||||
@@ -28,6 +28,20 @@ const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("clean modal reuses a detached tab without changing its dirty state or content", () => {
|
||||
const store = new EditorTabStore();
|
||||
const tab = makeTab({ placement: "window", windowDirty: true, content: "", baselineContent: "" });
|
||||
store._debugInsert(tab);
|
||||
const id = store.promoteFromModal({
|
||||
...makeTab(),
|
||||
remotePath: "/etc//nginx/./nginx.conf",
|
||||
});
|
||||
assert.equal(id, tab.id);
|
||||
assert.equal(store.getTab(id), tab);
|
||||
assert.equal(store.isDirty(id), true);
|
||||
assert.equal(store.getTabs().length, 1);
|
||||
});
|
||||
|
||||
test("updateContent stores content and viewState; dirty flag derives from baseline", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type * as Monaco from "monaco-editor";
|
||||
|
||||
import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore";
|
||||
import type { EditorTabPlacement, EditorWindowTabSnapshot } from "./editorWindowTypes";
|
||||
|
||||
// POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths
|
||||
// may contain semantic ".." segments we don't want to resolve client-side).
|
||||
@@ -23,6 +24,7 @@ export interface EditorTab {
|
||||
sftpTabId: string;
|
||||
/** Stable endpoint id; used to verify the session is still the one we opened against. */
|
||||
hostId: string;
|
||||
hostLabel?: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
languageId: string;
|
||||
@@ -32,12 +34,16 @@ export interface EditorTab {
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
savingState: EditorSavingState;
|
||||
saveError: string | null;
|
||||
placement?: EditorTabPlacement;
|
||||
windowDirty?: boolean;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let idCounter = 0;
|
||||
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
|
||||
const genId = (): EditorTabId => `edt_${crypto.randomUUID()}`;
|
||||
|
||||
export const tabIsDirty = (tab: EditorTab): boolean =>
|
||||
tab.placement === "window" ? tab.windowDirty === true : tab.content !== tab.baselineContent;
|
||||
|
||||
export class EditorTabStore {
|
||||
private tabs: EditorTab[] = [];
|
||||
@@ -79,7 +85,82 @@ export class EditorTabStore {
|
||||
};
|
||||
isDirty = (id: EditorTabId): boolean => {
|
||||
const t = this.getTab(id);
|
||||
return !!t && t.content !== t.baselineContent;
|
||||
return !!t && tabIsDirty(t);
|
||||
};
|
||||
|
||||
listByOwner = (owner: { sessionId?: string; sftpTabId?: string }): EditorTab[] =>
|
||||
this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
|
||||
|
||||
markDetached = (id: EditorTabId, dirty: boolean, releaseContent = true): void => {
|
||||
this.patch(id, {
|
||||
placement: "window",
|
||||
windowDirty: dirty,
|
||||
...(releaseContent ? { content: "", baselineContent: "", viewState: null } : {}),
|
||||
});
|
||||
this.notifyStructural();
|
||||
};
|
||||
|
||||
setWindowDirty = (id: EditorTabId, dirty: boolean): void => {
|
||||
const tab = this.getTab(id);
|
||||
if (!tab || tab.placement !== "window" || tab.windowDirty === dirty) return;
|
||||
this.patch(id, { windowDirty: dirty });
|
||||
};
|
||||
|
||||
upsertFromSnapshot = (
|
||||
snapshot: EditorWindowTabSnapshot,
|
||||
placement: EditorTabPlacement = "tab",
|
||||
): EditorTabId => {
|
||||
const existingById = snapshot.editorId ? this.getTab(snapshot.editorId) : undefined;
|
||||
const normalized = normalizePath(snapshot.remotePath);
|
||||
const existing = existingById ?? this.tabs.find(
|
||||
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
|
||||
);
|
||||
const next: Partial<EditorTab> = {
|
||||
sessionId: snapshot.sessionId,
|
||||
sftpTabId: snapshot.sftpTabId,
|
||||
hostId: snapshot.hostId,
|
||||
hostLabel: snapshot.hostLabel,
|
||||
remotePath: snapshot.remotePath,
|
||||
fileName: snapshot.fileName,
|
||||
languageId: snapshot.languageId,
|
||||
content: placement === "window" ? "" : snapshot.content,
|
||||
baselineContent: placement === "window" ? "" : snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: placement === "window" ? null : snapshot.viewState as EditorTab["viewState"],
|
||||
placement,
|
||||
windowDirty: placement === "window"
|
||||
? snapshot.content !== snapshot.baselineContent
|
||||
: false,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
};
|
||||
if (existing) {
|
||||
this.patch(existing.id, next);
|
||||
if (existing.placement !== placement) this.notifyStructural();
|
||||
return existing.id;
|
||||
}
|
||||
const tab: EditorTab = {
|
||||
id: snapshot.editorId || this.makeId(),
|
||||
kind: "editor",
|
||||
sessionId: snapshot.sessionId,
|
||||
sftpTabId: snapshot.sftpTabId,
|
||||
hostId: snapshot.hostId,
|
||||
hostLabel: snapshot.hostLabel,
|
||||
remotePath: snapshot.remotePath,
|
||||
fileName: snapshot.fileName,
|
||||
languageId: snapshot.languageId,
|
||||
content: next.content ?? "",
|
||||
baselineContent: next.baselineContent ?? "",
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: (next.viewState ?? null) as EditorTab["viewState"],
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
placement,
|
||||
windowDirty: next.windowDirty === true,
|
||||
};
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notifyStructural();
|
||||
return tab.id;
|
||||
};
|
||||
|
||||
updateContent = (
|
||||
@@ -91,6 +172,12 @@ export class EditorTabStore {
|
||||
};
|
||||
|
||||
markSaved = (id: EditorTabId, newBaseline: string) => {
|
||||
const tab = this.getTab(id);
|
||||
if (tab?.placement === "window") {
|
||||
// Only the detached renderer has the current content. Wait for its dirty report.
|
||||
this.patch(id, { savingState: "idle", saveError: null });
|
||||
return;
|
||||
}
|
||||
this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null });
|
||||
};
|
||||
|
||||
@@ -174,17 +261,26 @@ export class EditorTabStore {
|
||||
baselineContent: string;
|
||||
wordWrap: boolean;
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
placement?: EditorTabPlacement;
|
||||
}): EditorTabId => {
|
||||
const normalized = normalizePath(snapshot.remotePath);
|
||||
const existing = this.tabs.find(
|
||||
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.placement === "window") {
|
||||
if (snapshot.content !== snapshot.baselineContent) {
|
||||
throw new Error("This file is already open in the editor window. Your changes are still in this dialog. Dock or close the existing editor before transferring these changes.");
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
this.patch(existing.id, {
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
placement: snapshot.placement ?? existing.placement ?? "tab",
|
||||
windowDirty: false,
|
||||
// keep languageId/hostId/fileName stable; they shouldn't change for the same path
|
||||
});
|
||||
return existing.id;
|
||||
@@ -204,6 +300,8 @@ export class EditorTabStore {
|
||||
viewState: snapshot.viewState,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
placement: snapshot.placement ?? "tab",
|
||||
windowDirty: false,
|
||||
};
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notifyStructural();
|
||||
@@ -221,9 +319,9 @@ export class EditorTabStore {
|
||||
saveTab?: (tabId: EditorTabId) => Promise<void>,
|
||||
onCloseTab?: (tabId: EditorTabId) => void,
|
||||
): Promise<boolean> => {
|
||||
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
|
||||
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner) && t.placement !== "window");
|
||||
for (const tab of matching) {
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
const dirty = tabIsDirty(tab);
|
||||
if (!dirty) {
|
||||
onCloseTab?.(tab.id);
|
||||
this.close(tab.id);
|
||||
@@ -332,9 +430,11 @@ export type EditorTabChrome = Pick<
|
||||
| 'sessionId'
|
||||
| 'sftpTabId'
|
||||
| 'hostId'
|
||||
| 'hostLabel'
|
||||
| 'remotePath'
|
||||
| 'fileName'
|
||||
| 'languageId'
|
||||
| 'placement'
|
||||
>;
|
||||
|
||||
const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
|
||||
@@ -343,9 +443,11 @@ const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
|
||||
sessionId: tab.sessionId,
|
||||
sftpTabId: tab.sftpTabId,
|
||||
hostId: tab.hostId,
|
||||
hostLabel: tab.hostLabel,
|
||||
remotePath: tab.remotePath,
|
||||
fileName: tab.fileName,
|
||||
languageId: tab.languageId,
|
||||
placement: tab.placement ?? "tab",
|
||||
});
|
||||
|
||||
export const useHasEditorTabForSessions = (
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
import { editorTabStore } from "./editorTabStore.ts";
|
||||
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
|
||||
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
|
||||
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
|
||||
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
|
||||
|
||||
const snapshot = {
|
||||
editorId: "save-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
|
||||
remotePath: "/file", fileName: "file", languageId: "plaintext",
|
||||
content: "v2", baselineContent: "v1", wordWrap: false, viewState: null,
|
||||
};
|
||||
|
||||
for (const latestContent of ["v3", "v2", "v1"]) {
|
||||
test(`detached save reports current dirty state after editing to ${latestContent}`, async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
let finish!: (result: { ok: boolean }) => void;
|
||||
const pending = new Promise<{ ok: boolean }>((resolve) => { finish = resolve; });
|
||||
const reports: boolean[] = [];
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
saveEditorWindowTab: (request: { content: string }) => {
|
||||
assert.equal(request.content, "v2");
|
||||
return pending;
|
||||
},
|
||||
reportEditorWindowDirty: ({ dirty }: { dirty: boolean }) => reports.push(dirty),
|
||||
}));
|
||||
const saving = saveDetachedEditorTab(snapshot.editorId);
|
||||
editorTabStore.updateContent(snapshot.editorId, latestContent, null);
|
||||
finish({ ok: true });
|
||||
assert.deepEqual(await saving, { ok: true });
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v2");
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, latestContent);
|
||||
assert.deepEqual(reports, [latestContent !== "v2"]);
|
||||
});
|
||||
}
|
||||
|
||||
test("source save receipt preserves dirty until the detached renderer reports it", async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot, "window");
|
||||
t.after(() => {
|
||||
editorTabStore.close(snapshot.editorId);
|
||||
releaseEditorTabSaveCoordinator(snapshot.editorId);
|
||||
});
|
||||
let finish!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { finish = resolve; });
|
||||
t.after(registerEditorSftpWriterScoped(async () => { await pending; return "conn"; }));
|
||||
let onSave!: (request: EditorWindowSaveRequest) => Promise<void>;
|
||||
let onDirty!: (payload: { editorId: string; dirty: boolean }) => void;
|
||||
const receipts: EditorWindowSaveResult[] = [];
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onEditorWindowSaveRequest: (cb: typeof onSave) => { onSave = cb; return () => {}; },
|
||||
onEditorWindowDirtyChanged: (cb: typeof onDirty) => { onDirty = cb; return () => {}; },
|
||||
reportEditorWindowSaveResult: (result: EditorWindowSaveResult) => receipts.push(result),
|
||||
}));
|
||||
t.after(installEditorWindowSourceListeners());
|
||||
const saving = onSave({ ...snapshot, requestId: "request" });
|
||||
onDirty({ editorId: snapshot.editorId, dirty: true });
|
||||
finish();
|
||||
await saving;
|
||||
assert.equal(receipts[0]?.ok, true);
|
||||
assert.equal(editorTabStore.isDirty(snapshot.editorId), true);
|
||||
onDirty({ editorId: snapshot.editorId, dirty: false });
|
||||
assert.equal(editorTabStore.isDirty(snapshot.editorId), false);
|
||||
});
|
||||
|
||||
for (const outcome of ["accepted", "rejected", "failed"] as const) {
|
||||
test(`popout retains source contents until the receiver ${outcome}`, async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
let resolve!: (result: { success: boolean }) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const receipt = new Promise<{ success: boolean }>((yes, no) => { resolve = yes; reject = no; });
|
||||
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: () => receipt }));
|
||||
const opening = popOutEditorTab(snapshot.editorId);
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.placement, "window");
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v2");
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v1");
|
||||
if (outcome === "failed") reject(new Error("IPC disconnected"));
|
||||
else resolve({ success: outcome === "accepted" });
|
||||
assert.equal(await opening, outcome === "accepted");
|
||||
const tab = editorTabStore.getTab(snapshot.editorId)!;
|
||||
assert.equal(tab.placement, outcome === "accepted" ? "window" : "tab");
|
||||
assert.equal(tab.content, outcome === "accepted" ? "" : "v2");
|
||||
assert.equal(tab.baselineContent, outcome === "accepted" ? "" : "v1");
|
||||
assert.equal(editorTabStore.isDirty(tab.id), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const outcome of ["accepted", "rejected", "failed"] as const) {
|
||||
test(`dock freezes the current copy until ${outcome} and refuses a second transfer`, async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
editorTabStore.setSavingState(snapshot.editorId, "error", "previous save failed");
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
let resolve!: (result: { success: boolean; error?: string }) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const receipt = new Promise<{ success: boolean; error?: string }>((yes, no) => { resolve = yes; reject = no; });
|
||||
let calls = 0;
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
dockEditorWindowTab: (sent: typeof snapshot) => {
|
||||
calls++;
|
||||
assert.equal(sent.content, "v2");
|
||||
return receipt;
|
||||
},
|
||||
}));
|
||||
const docking = dockDetachedEditorTab(snapshot.editorId);
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving", "existing pane busy state makes Monaco read-only during transfer");
|
||||
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
|
||||
assert.equal(calls, 1);
|
||||
if (outcome === "failed") reject(new Error("IPC disconnected"));
|
||||
else resolve({ success: outcome === "accepted", error: "Source unavailable" });
|
||||
assert.equal((await docking).success, outcome === "accepted");
|
||||
const tab = editorTabStore.getTab(snapshot.editorId)!;
|
||||
assert.equal(tab.content, "v2");
|
||||
assert.equal(tab.baselineContent, "v1");
|
||||
assert.equal(tab.savingState, outcome === "accepted" ? "saving" : "error", "success stays frozen until the caller closes; failure restores interaction");
|
||||
if (outcome !== "accepted") {
|
||||
assert.equal(tab.saveError, "previous save failed");
|
||||
editorTabStore.updateContent(snapshot.editorId, "v3", null);
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v3");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("dock does not take over an in-progress save", async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
editorTabStore.setSavingState(snapshot.editorId, "saving");
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
let calls = 0;
|
||||
t.mock.method(netcattyBridge, "get", () => ({ dockEditorWindowTab: async () => { calls++; return { success: true }; } }));
|
||||
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving");
|
||||
});
|
||||
|
||||
test("a closing destination rejects a popout without releasing source content", async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: async () => ({ success: false, error: "Editor window is closing" }) }));
|
||||
assert.equal(await popOutEditorTab(snapshot.editorId), false);
|
||||
const tab = editorTabStore.getTab(snapshot.editorId)!;
|
||||
assert.equal(tab.placement, "tab");
|
||||
assert.equal(tab.content, "v2");
|
||||
assert.equal(tab.baselineContent, "v1");
|
||||
assert.equal(editorTabStore.isDirty(tab.id), true);
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { saveEditorTab } from "./editorTabSave";
|
||||
import {
|
||||
editorTabStore,
|
||||
tabIsDirty,
|
||||
type EditorTab,
|
||||
type EditorTabId,
|
||||
} from "./editorTabStore";
|
||||
import type { EditorWindowTabSnapshot } from "./editorWindowTypes";
|
||||
import { activeTabStore, toEditorTabId } from "./activeTabStore";
|
||||
|
||||
export function toEditorWindowSnapshot(
|
||||
tab: EditorTab,
|
||||
hostLabel?: string,
|
||||
): EditorWindowTabSnapshot {
|
||||
return {
|
||||
editorId: tab.id,
|
||||
sessionId: tab.sessionId,
|
||||
sftpTabId: tab.sftpTabId,
|
||||
hostId: tab.hostId,
|
||||
hostLabel: hostLabel || tab.hostLabel,
|
||||
remotePath: tab.remotePath,
|
||||
fileName: tab.fileName,
|
||||
languageId: tab.languageId,
|
||||
content: tab.content,
|
||||
baselineContent: tab.baselineContent,
|
||||
wordWrap: tab.wordWrap,
|
||||
viewState: tab.viewState,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openEditorInWindow(snapshot: EditorWindowTabSnapshot): Promise<boolean> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openEditorWindow) return false;
|
||||
const result = await bridge.openEditorWindow(snapshot);
|
||||
return result?.success === true;
|
||||
}
|
||||
|
||||
export async function focusEditorInWindow(editorId: EditorTabId): Promise<boolean> {
|
||||
const result = await netcattyBridge.get()?.focusEditorWindow?.(editorId);
|
||||
return result?.success === true;
|
||||
}
|
||||
|
||||
export async function popOutEditorTab(
|
||||
tabId: EditorTabId,
|
||||
hostLabel?: string,
|
||||
): Promise<boolean> {
|
||||
const tab = editorTabStore.getTab(tabId);
|
||||
if (!tab) return false;
|
||||
if (tab.placement === "window") {
|
||||
return focusEditorInWindow(tabId);
|
||||
}
|
||||
const dirty = tabIsDirty(tab);
|
||||
const snapshot = toEditorWindowSnapshot(tab, hostLabel || tab.hostLabel);
|
||||
// Hide the source editor during transfer, but retain its only owned copy
|
||||
// until the destination has acknowledged installing the tab.
|
||||
editorTabStore.markDetached(tabId, dirty, false);
|
||||
const ok = await openEditorInWindow(snapshot).catch(() => false);
|
||||
if (!ok) {
|
||||
if (editorTabStore.getTab(tabId)) editorTabStore.upsertFromSnapshot(snapshot, "tab");
|
||||
return false;
|
||||
}
|
||||
editorTabStore.markDetached(tabId, dirty);
|
||||
const activeId = activeTabStore.getActiveTabId();
|
||||
if (activeId === toEditorTabId(tabId)) {
|
||||
activeTabStore.setActiveTabId("vault");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function closeOwnedEditorWindowTabs(
|
||||
owner: { sessionId?: string; sftpTabId?: string },
|
||||
options?: { force?: boolean },
|
||||
): Promise<{ cancelled: boolean; closedIds: string[] }> {
|
||||
const matching = editorTabStore.listByOwner(owner).filter((tab) => tab.placement === "window");
|
||||
if (matching.length === 0) return { cancelled: false, closedIds: [] };
|
||||
const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({
|
||||
editorIds: matching.map((tab) => tab.id),
|
||||
force: options?.force === true,
|
||||
});
|
||||
const closedIds = result?.closedIds ?? [];
|
||||
for (const id of closedIds) editorTabStore.close(id);
|
||||
if (!result || result.success === false) {
|
||||
return { cancelled: options?.force !== true, closedIds };
|
||||
}
|
||||
if (result.cancelled !== true) {
|
||||
for (const tab of matching) editorTabStore.close(tab.id);
|
||||
}
|
||||
return {
|
||||
cancelled: result.cancelled === true,
|
||||
closedIds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmCloseOwnedEditors(
|
||||
owner: { sessionId?: string; sftpTabId?: string },
|
||||
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
|
||||
saveTab?: (tabId: EditorTabId) => Promise<void>,
|
||||
onCloseTab?: (tabId: EditorTabId) => void,
|
||||
): Promise<boolean> {
|
||||
const windowClose = await closeOwnedEditorWindowTabs(owner, { force: false });
|
||||
if (windowClose.cancelled) return false;
|
||||
return editorTabStore.confirmCloseByOwner(owner, promptChoice, saveTab, onCloseTab);
|
||||
}
|
||||
|
||||
export function forceCloseOwnedEditors(owners: {
|
||||
sessionIds?: readonly string[];
|
||||
sftpTabIds?: readonly string[];
|
||||
}): EditorTabId[] {
|
||||
const matching = editorTabStore.getTabs().filter((tab) => {
|
||||
if (tab.placement !== "window") return false;
|
||||
if (owners.sessionIds?.includes(tab.sessionId)) return true;
|
||||
if (owners.sftpTabIds?.includes(tab.sftpTabId)) return true;
|
||||
return false;
|
||||
});
|
||||
if (matching.length > 0) {
|
||||
void netcattyBridge.get()?.closeEditorWindowTabs?.({
|
||||
editorIds: matching.map((tab) => tab.id),
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
return editorTabStore.forceCloseByOwners(owners);
|
||||
}
|
||||
|
||||
export function notifyEditorWindowSessionRemap(fromSessionId: string, toSessionId: string): void {
|
||||
if (!fromSessionId || !toSessionId || fromSessionId === toSessionId) return;
|
||||
netcattyBridge.get()?.remapEditorWindowSession?.({ fromSessionId, toSessionId });
|
||||
}
|
||||
|
||||
export async function saveDetachedEditorTab(tabId: EditorTabId): Promise<{ ok: boolean; error?: string }> {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (!current) return { ok: false, error: "Editor tab closed before save completed" };
|
||||
editorTabStore.setSavingState(tabId, "saving");
|
||||
const result = await netcattyBridge.get()?.saveEditorWindowTab?.({
|
||||
editorId: current.id,
|
||||
sessionId: current.sessionId,
|
||||
sftpTabId: current.sftpTabId,
|
||||
hostId: current.hostId,
|
||||
remotePath: current.remotePath,
|
||||
content: current.content,
|
||||
});
|
||||
if (result?.ok) {
|
||||
editorTabStore.markSaved(tabId, current.content);
|
||||
netcattyBridge.get()?.reportEditorWindowDirty?.({
|
||||
editorId: tabId,
|
||||
dirty: editorTabStore.isDirty(tabId),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
const error = result?.error || "Save failed";
|
||||
editorTabStore.setSavingState(tabId, "error", error);
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
export async function dockDetachedEditorTab(tabId: EditorTabId): Promise<{ success: boolean; error?: string }> {
|
||||
const tab = editorTabStore.getTab(tabId);
|
||||
if (!tab) return { success: false, error: "Editor tab closed" };
|
||||
if (tab.savingState === "saving") return { success: false, error: "Editor tab is busy" };
|
||||
// Keep the snapshot's owner read-only until receipt. Success stays busy
|
||||
// until the caller removes this copy; failure restores its previous state.
|
||||
editorTabStore.setSavingState(tabId, "saving");
|
||||
let accepted = false;
|
||||
try {
|
||||
const result = await netcattyBridge.get()?.dockEditorWindowTab?.(
|
||||
toEditorWindowSnapshot(tab, tab.hostLabel),
|
||||
);
|
||||
if (result?.success) {
|
||||
accepted = true;
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result?.error || "Failed to dock editor tab" };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Failed to dock editor tab" };
|
||||
} finally {
|
||||
if (!accepted) editorTabStore.setSavingState(tabId, tab.savingState, tab.saveError);
|
||||
}
|
||||
}
|
||||
|
||||
export function reportDetachedEditorDirty(tabId: EditorTabId, dirty: boolean): void {
|
||||
netcattyBridge.get()?.reportEditorWindowDirty?.({ editorId: tabId, dirty });
|
||||
}
|
||||
|
||||
export function reportDetachedEditorTabsClosed(editorIds: string[]): void {
|
||||
if (editorIds.length === 0) return;
|
||||
netcattyBridge.get()?.reportEditorWindowTabsClosed?.({ editorIds });
|
||||
}
|
||||
|
||||
export function subscribeEditorWindowCloseTabs(
|
||||
cb: (payload: import("./editorWindowTypes").EditorWindowCloseTabsRequest) => void | Promise<void>,
|
||||
): () => void {
|
||||
return netcattyBridge.get()?.onEditorWindowCloseTabs?.(cb) ?? (() => {});
|
||||
}
|
||||
|
||||
export function reportEditorWindowCloseTabsResult(
|
||||
payload: import("./editorWindowTypes").EditorWindowCloseTabsResult,
|
||||
): void {
|
||||
netcattyBridge.get()?.reportEditorWindowCloseTabsResult?.(payload);
|
||||
}
|
||||
|
||||
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return () => {};
|
||||
return bridge.onCheckDirtyEditors(() => {
|
||||
let hasDirty = false;
|
||||
try {
|
||||
hasDirty = cb() === true;
|
||||
} catch {
|
||||
hasDirty = false;
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function installEditorWindowSourceListeners(): () => void {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) return () => {};
|
||||
|
||||
const unsubSave = bridge.onEditorWindowSaveRequest?.(async (payload) => {
|
||||
try {
|
||||
const tab = editorTabStore.getTab(payload.editorId);
|
||||
if (!tab) {
|
||||
bridge.reportEditorWindowSaveResult?.({
|
||||
requestId: payload.requestId,
|
||||
ok: false,
|
||||
error: "Editor tab closed before save completed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (tab.sessionId !== payload.sessionId) {
|
||||
editorTabStore.remapSessionId(tab.sessionId, payload.sessionId);
|
||||
}
|
||||
const ok = await saveEditorTab(payload.editorId, payload.content);
|
||||
const latest = editorTabStore.getTab(payload.editorId);
|
||||
bridge.reportEditorWindowSaveResult?.({
|
||||
requestId: payload.requestId,
|
||||
ok,
|
||||
liveConnectionId: latest?.sessionId,
|
||||
error: ok ? undefined : (latest?.saveError || "Save failed"),
|
||||
});
|
||||
} catch (err) {
|
||||
bridge.reportEditorWindowSaveResult?.({
|
||||
requestId: payload.requestId,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Save failed",
|
||||
});
|
||||
}
|
||||
}) ?? (() => {});
|
||||
|
||||
const unsubDock = bridge.onEditorWindowDockRequest?.((payload) => {
|
||||
try {
|
||||
const id = editorTabStore.upsertFromSnapshot(payload, "tab");
|
||||
activeTabStore.setActiveTabId(toEditorTabId(id));
|
||||
bridge.reportEditorWindowDockResult?.({ requestId: payload.requestId, ok: true });
|
||||
} catch (err) {
|
||||
bridge.reportEditorWindowDockResult?.({
|
||||
requestId: payload.requestId,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Failed to dock",
|
||||
});
|
||||
}
|
||||
}) ?? (() => {});
|
||||
|
||||
const unsubDirty = bridge.onEditorWindowDirtyChanged?.((payload) => {
|
||||
editorTabStore.setWindowDirty(payload.editorId, payload.dirty === true);
|
||||
}) ?? (() => {});
|
||||
|
||||
const unsubClosed = bridge.onEditorWindowTabsClosed?.((payload) => {
|
||||
for (const editorId of payload.editorIds ?? []) {
|
||||
editorTabStore.close(editorId);
|
||||
}
|
||||
}) ?? (() => {});
|
||||
|
||||
return () => {
|
||||
unsubSave();
|
||||
unsubDock();
|
||||
unsubDirty();
|
||||
unsubClosed();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type EditorTabPlacement = "tab" | "window";
|
||||
|
||||
export interface EditorWindowTabSnapshot {
|
||||
editorId: string;
|
||||
sessionId: string;
|
||||
sftpTabId: string;
|
||||
hostId: string;
|
||||
hostLabel?: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
languageId: string;
|
||||
content: string;
|
||||
baselineContent: string;
|
||||
wordWrap: boolean;
|
||||
viewState: unknown;
|
||||
}
|
||||
|
||||
export interface EditorWindowSaveRequest {
|
||||
requestId: string;
|
||||
editorId: string;
|
||||
sessionId: string;
|
||||
sftpTabId: string;
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface EditorWindowSaveResult {
|
||||
requestId: string;
|
||||
ok: boolean;
|
||||
liveConnectionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface EditorWindowCloseTabsRequest {
|
||||
requestId: string;
|
||||
editorIds: string[];
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface EditorWindowCloseTabsResult {
|
||||
requestId: string;
|
||||
cancelled: boolean;
|
||||
closedIds: string[];
|
||||
}
|
||||
@@ -209,3 +209,13 @@ test("restore terminal cwd setting participates in cross-window settings sync",
|
||||
assert.match(ipcSyncSource, /setRestoreTerminalCwdState/);
|
||||
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
|
||||
});
|
||||
|
||||
test("editor route keeps IPC/storage synchronization while disabling system effects", () => {
|
||||
const source = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
|
||||
const expression = source.match(/const settingsOptions =\s*([^;]+);/s)?.[1];
|
||||
assert.ok(expression, "renderer settings options must be explicit");
|
||||
const resolveOptions = new Function("isPeerSessionWindow", "isEditorWindow", `return (${expression});`);
|
||||
assert.deepEqual(resolveOptions(false, true), { enableSystemEffects: false });
|
||||
assert.deepEqual(resolveOptions(true, false), { enableSettingsSync: false, enableSystemEffects: false });
|
||||
assert.equal(resolveOptions(false, false), undefined);
|
||||
});
|
||||
@@ -79,6 +79,7 @@ import { localStorageAdapter } from "../../../infrastructure/persistence/localSt
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { editorTabStore } from "../editorTabStore";
|
||||
import { notifyEditorWindowSessionRemap } from "../editorWindowClient";
|
||||
import {
|
||||
resolveUploadStreamTargetSftpId,
|
||||
} from "../../../domain/sftpDedicatedStreamPolicy";
|
||||
@@ -443,6 +444,7 @@ export const useSftpExternalOperations = (
|
||||
|
||||
if (liveConnectionId !== connectionId) {
|
||||
editorTabStore.remapSessionId(connectionId, liveConnectionId);
|
||||
notifyEditorWindowSessionRemap(connectionId, liveConnectionId);
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { editorTabStore } from "./editorTabStore";
|
||||
import type { EditorWindowTabSnapshot } from "./editorWindowTypes";
|
||||
import type { EditorTabId } from "./editorTabStore";
|
||||
|
||||
export function useEditorWindowRuntime() {
|
||||
const [activeTabId, setActiveTabId] = useState<EditorTabId | null>(
|
||||
() => editorTabStore.getTabs()[editorTabStore.getTabs().length - 1]?.id ?? null,
|
||||
);
|
||||
|
||||
const closeWindow = useCallback(async () => {
|
||||
await netcattyBridge.get()?.windowClose?.();
|
||||
}, []);
|
||||
|
||||
const setWindowTitle = useCallback(async (title: string) => {
|
||||
await netcattyBridge.get()?.setWindowTitle?.(title);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onEditorWindowOpenTab) return () => {};
|
||||
return bridge.onEditorWindowOpenTab((snapshot: EditorWindowTabSnapshot) => {
|
||||
const existing = editorTabStore.getTab(snapshot.editorId)
|
||||
?? editorTabStore.getTabs().find((tab) => (
|
||||
tab.sessionId === snapshot.sessionId && tab.remotePath === snapshot.remotePath
|
||||
));
|
||||
if (existing) {
|
||||
if (existing.sessionId !== snapshot.sessionId) {
|
||||
editorTabStore.remapSessionId(existing.sessionId, snapshot.sessionId);
|
||||
}
|
||||
if (existing.sftpTabId !== snapshot.sftpTabId || existing.hostLabel !== snapshot.hostLabel) {
|
||||
editorTabStore.upsertFromSnapshot({
|
||||
...snapshot,
|
||||
content: existing.content,
|
||||
baselineContent: existing.baselineContent,
|
||||
viewState: existing.viewState,
|
||||
wordWrap: existing.wordWrap,
|
||||
languageId: existing.languageId,
|
||||
}, "tab");
|
||||
}
|
||||
setActiveTabId(existing.id);
|
||||
return;
|
||||
}
|
||||
const id = editorTabStore.upsertFromSnapshot(snapshot, "tab");
|
||||
setActiveTabId(id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onEditorWindowActivateTab) return () => {};
|
||||
return bridge.onEditorWindowActivateTab((payload) => {
|
||||
if (payload?.editorId && editorTabStore.getTab(payload.editorId)) {
|
||||
setActiveTabId(payload.editorId);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onEditorWindowRemapSession) return () => {};
|
||||
return bridge.onEditorWindowRemapSession((payload) => {
|
||||
editorTabStore.remapSessionId(payload.fromSessionId, payload.toSessionId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeTabId,
|
||||
setActiveTabId,
|
||||
closeWindow,
|
||||
setWindowTitle,
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
editorTabStore,
|
||||
useEditorTabPresenceRevision,
|
||||
} from "../application/state/editorTabStore";
|
||||
import { forceCloseOwnedEditors } from "../application/state/editorWindowClient";
|
||||
import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
@@ -532,7 +533,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (id) ownedSessionIds.push(id);
|
||||
}
|
||||
if (ownedSessionIds.length === 0 && ownedSftpTabIds.length === 0) return;
|
||||
const closed = editorTabStore.forceCloseByOwners({
|
||||
const closed = forceCloseOwnedEditors({
|
||||
sessionIds: ownedSessionIds,
|
||||
sftpTabIds: ownedSftpTabIds,
|
||||
});
|
||||
@@ -1529,6 +1530,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
onPopOut,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
@@ -1999,6 +2001,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
onPopOut={onPopOut}
|
||||
onRequestTerminalFocus={onRequestTerminalFocus}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -410,6 +410,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
onPopOut,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
@@ -797,6 +798,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
onPopOut={onPopOut}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,8 @@ interface TextEditorModalProps {
|
||||
keyBindings: KeyBinding[];
|
||||
/** If provided, a maximize button is shown in the Pane header. */
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
/** If provided, a pop-out button sends the file to the dedicated editor window. */
|
||||
onPopOut?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
}
|
||||
|
||||
const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
@@ -84,6 +86,7 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onPromoteToTab,
|
||||
onPopOut,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -229,6 +232,20 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
if (snapshot) onPromoteToTab(snapshot);
|
||||
}, [onPromoteToTab, fileName, languageId, editorWordWrap]);
|
||||
|
||||
const handlePopOut = useCallback(() => {
|
||||
if (!onPopOut) return;
|
||||
const snapshot = createTextEditorModalSnapshot({
|
||||
fileName,
|
||||
getBaselineContent: () => baselineContentRef.current,
|
||||
getContent: () => contentRef.current,
|
||||
languageId,
|
||||
wordWrap: editorWordWrap,
|
||||
getViewState: () => viewStateRef.current,
|
||||
isSaving: () => savingRef.current,
|
||||
});
|
||||
if (snapshot) onPopOut(snapshot);
|
||||
}, [onPopOut, fileName, languageId, editorWordWrap]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent
|
||||
@@ -255,6 +272,7 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
onSave={handleSave}
|
||||
onRequestClose={handleClose}
|
||||
onPromoteToTab={onPromoteToTab ? handlePromote : undefined}
|
||||
onPopOut={onPopOut ? handlePopOut : undefined}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -173,6 +173,8 @@ interface TopTabsProps {
|
||||
pluginViewTabs: readonly PluginViewTab[];
|
||||
onClosePluginViewTab: (tabId: string) => void;
|
||||
onRequestCloseEditorTab: (editorTabId: string) => void;
|
||||
onPopOutEditorTab?: (editorTabId: string) => void;
|
||||
onFocusEditorWindowTab?: (editorTabId: string) => void;
|
||||
hostById: Map<string, Host>;
|
||||
}
|
||||
|
||||
@@ -223,6 +225,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
pluginViewTabs,
|
||||
onClosePluginViewTab,
|
||||
onRequestCloseEditorTab,
|
||||
onPopOutEditorTab,
|
||||
onFocusEditorWindowTab,
|
||||
hostById,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -832,6 +836,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
host={host}
|
||||
suffix={suffix}
|
||||
onRequestCloseEditorTab={onRequestCloseEditorTab}
|
||||
onPopOutEditorTab={onPopOutEditorTab}
|
||||
onFocusEditorWindowTab={onFocusEditorWindowTab}
|
||||
isBeingDragged={isBeingDragged}
|
||||
isDraggingForReorder={isDraggingForReorder}
|
||||
shiftStyle={shiftStyle}
|
||||
@@ -1302,6 +1308,8 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
|
||||
// Editor open/close/fileName chrome only (presence list). Dirty dots use store.
|
||||
prev.editorTabs === next.editorTabs &&
|
||||
prev.onRequestCloseEditorTab === next.onRequestCloseEditorTab &&
|
||||
prev.onPopOutEditorTab === next.onPopOutEditorTab &&
|
||||
prev.onFocusEditorWindowTab === next.onFocusEditorWindowTab &&
|
||||
prev.pluginViewTabs === next.pluginViewTabs &&
|
||||
prev.onClosePluginViewTab === next.onClosePluginViewTab &&
|
||||
prev.draggingSessionId === next.draggingSessionId &&
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
test("the custom window close button delegates before touching any renderer-owned tabs", async () => {
|
||||
const source = readFileSync(new URL("./EditorWindowPage.tsx", import.meta.url), "utf8");
|
||||
const body = source.match(/const handleWindowClose = useCallback\(async \(\) => \{([\s\S]*?)\n {2}\}, \[/)?.[1];
|
||||
assert.ok(body);
|
||||
let closes = 0;
|
||||
const fail = () => { throw new Error("Native close must own tab confirmation and the incoming-transfer gate"); };
|
||||
const close = new Function("closeWindow", "editorTabStore", "promptUnsavedChanges", "saveTab", "reportDetachedEditorTabsClosed", `return async () => {${body}}`)(
|
||||
async () => { closes++; }, { getTabs: fail, close: fail }, fail, fail, fail,
|
||||
);
|
||||
await close();
|
||||
assert.equal(closes, 1);
|
||||
assert.match(source, /<EditorWindowControls onClose=\{\(\) => \{ void handleWindowClose\(\); \}\}/);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
test("a failed detached save re-reports the tab's dirty state after the debounce clear", async () => {
|
||||
const source = readFileSync(new URL("./EditorWindowPage.tsx", import.meta.url), "utf8");
|
||||
const body = source.match(/const handleSave = useCallback\(async \(\) => \{([\s\S]*?)\n {2}\}, \[tabId, t\]\)/)?.[1];
|
||||
assert.ok(body, "handleSave body not found");
|
||||
|
||||
const timers = [];
|
||||
const globalWindow = {
|
||||
clearTimeout: (id) => { timers.splice(timers.indexOf(id), 1); },
|
||||
setTimeout: (fn, ms) => { const id = `t${timers.length}`; timers.push(id); return id; },
|
||||
};
|
||||
const reports = [];
|
||||
const tab = { placement: "window", windowDirty: true, content: "edited", baselineContent: "original" };
|
||||
const store = { getTab: (tabId) => (tabId === "edt_1" ? tab : null) };
|
||||
const savedToasts = [];
|
||||
const deps = {
|
||||
window: globalWindow,
|
||||
saveDetachedEditorTab: async () => ({ ok: false, error: "disk full" }),
|
||||
saveDetachedEditorTabOk: async () => ({ ok: true }),
|
||||
editorTabStore: store,
|
||||
tabIsDirty: () => true,
|
||||
reportDetachedEditorDirty: (tabId, dirty) => { reports.push({ tabId, dirty }); },
|
||||
toast: { error: () => savedToasts.push("error"), success: () => savedToasts.push("success") },
|
||||
t: (key) => key,
|
||||
};
|
||||
|
||||
// Cancelled the pending dirty report before the save started, then the
|
||||
// failure path must re-report the current dirty state.
|
||||
const run = new Function(
|
||||
...Object.keys(deps), "tabId", "dirtyReportTimerRef",
|
||||
`return async () => {${body}}`,
|
||||
)(...Object.values(deps), "edt_1", { current: timers[0] ?? "t0" });
|
||||
|
||||
timers.push("t0");
|
||||
await run();
|
||||
|
||||
assert.equal(savedToasts.at(-1), "error");
|
||||
assert.deepEqual(reports, [{ tabId: "edt_1", dirty: true }]);
|
||||
assert.deepEqual(timers, [], "pending dirty report must be cancelled before the save");
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import { Copy, Minus, Square, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type * as Monaco from "monaco-editor";
|
||||
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import {
|
||||
editorTabStore,
|
||||
tabIsDirty,
|
||||
useEditorTab,
|
||||
useEditorTabChromeList,
|
||||
useEditorTabDirty,
|
||||
type EditorTabId,
|
||||
} from "../../application/state/editorTabStore";
|
||||
import {
|
||||
dockDetachedEditorTab,
|
||||
reportDetachedEditorDirty,
|
||||
reportDetachedEditorTabsClosed,
|
||||
reportEditorWindowCloseTabsResult,
|
||||
saveDetachedEditorTab,
|
||||
subscribeEditorWindowCloseTabs,
|
||||
subscribeEditorWindowDirtyCheck,
|
||||
} from "../../application/state/editorWindowClient";
|
||||
import { useEditorWindowRuntime } from "../../application/state/useEditorWindow";
|
||||
import { useWindowControls } from "../../application/state/useWindowControls";
|
||||
import type { AppLockGateRenderContext } from "../AppLockGate";
|
||||
import { toast } from "../ui/toast";
|
||||
import { promptUnsavedChanges, UnsavedChangesProvider } from "./UnsavedChangesDialog";
|
||||
import { EditorWindowTabBar } from "./EditorWindowTabBar";
|
||||
import { TextEditorPane } from "./TextEditorPane";
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
type SettingsState = AppLockGateRenderContext["settings"];
|
||||
|
||||
function EditorWindowControls({ onClose }: { onClose: () => void }) {
|
||||
const { minimize, maximize, isMaximized: fetchIsMaximized } = useWindowControls();
|
||||
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchIsMaximized().then((value) => {
|
||||
if (!cancelled) setIsWindowMaximized(!!value);
|
||||
});
|
||||
const handleResize = () => {
|
||||
void fetchIsMaximized().then((value) => setIsWindowMaximized(!!value));
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [fetchIsMaximized]);
|
||||
|
||||
if (isMac) return null;
|
||||
|
||||
const buttonClass =
|
||||
"app-no-drag flex h-10 w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
|
||||
|
||||
return (
|
||||
<div className="app-no-drag ml-auto flex h-10 shrink-0 items-center">
|
||||
<button type="button" onClick={() => void minimize()} className={buttonClass} aria-label="Minimize">
|
||||
<Minus size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const value = await maximize();
|
||||
setIsWindowMaximized(!!value);
|
||||
}}
|
||||
className={buttonClass}
|
||||
aria-label={isWindowMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{isWindowMaximized ? <Copy size={14} /> : <Square size={13} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="app-no-drag flex h-10 w-11 items-center justify-center text-foreground opacity-80 transition-colors hover:bg-destructive/20 hover:opacity-100"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorWindowPaneHost({
|
||||
tabId,
|
||||
isVisible,
|
||||
settings,
|
||||
onRequestClose,
|
||||
onDock,
|
||||
}: {
|
||||
tabId: EditorTabId;
|
||||
isVisible: boolean;
|
||||
settings: SettingsState;
|
||||
onRequestClose: (tabId: EditorTabId) => void;
|
||||
onDock: (tabId: EditorTabId) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const tab = useEditorTab(tabId);
|
||||
const dirtyReportTimerRef = useRef<number | null>(null);
|
||||
const handleContentChange = useCallback(
|
||||
(content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
|
||||
editorTabStore.updateContent(tabId, content, viewState);
|
||||
const latest = editorTabStore.getTab(tabId);
|
||||
if (!latest) return;
|
||||
const dirty = content !== latest.baselineContent;
|
||||
if (dirtyReportTimerRef.current != null) window.clearTimeout(dirtyReportTimerRef.current);
|
||||
dirtyReportTimerRef.current = window.setTimeout(() => {
|
||||
dirtyReportTimerRef.current = null;
|
||||
reportDetachedEditorDirty(tabId, dirty);
|
||||
}, 150);
|
||||
},
|
||||
[tabId],
|
||||
);
|
||||
useEffect(() => () => {
|
||||
if (dirtyReportTimerRef.current != null) window.clearTimeout(dirtyReportTimerRef.current);
|
||||
}, []);
|
||||
const handleSave = useCallback(async () => {
|
||||
if (dirtyReportTimerRef.current != null) {
|
||||
window.clearTimeout(dirtyReportTimerRef.current);
|
||||
dirtyReportTimerRef.current = null;
|
||||
}
|
||||
const result = await saveDetachedEditorTab(tabId);
|
||||
if (result.ok) {
|
||||
toast.success(t("sftp.editor.saved"), "SFTP");
|
||||
} else {
|
||||
toast.error(result.error ?? t("sftp.editor.saveFailed"), "SFTP");
|
||||
// The pre-save debounce clear removed the pending dirty report, so a
|
||||
// failed save must re-report the tab's current dirty state or the
|
||||
// source placeholder/top-tab indicator will stay marked clean.
|
||||
const latest = editorTabStore.getTab(tabId);
|
||||
if (latest) reportDetachedEditorDirty(tabId, tabIsDirty(latest));
|
||||
}
|
||||
}, [tabId, t]);
|
||||
|
||||
if (!tab) return null;
|
||||
const isDirty = tabIsDirty(tab);
|
||||
const hostLabel = tab.hostLabel || tab.hostId;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 flex min-h-0 flex-col bg-background"
|
||||
style={isVisible ? undefined : { pointerEvents: "none", visibility: "hidden" }}
|
||||
>
|
||||
<TextEditorPane
|
||||
chrome="window"
|
||||
fileName={`${tab.fileName}${isDirty ? " *" : ""}`}
|
||||
subtitle={`${hostLabel}:${tab.remotePath}`}
|
||||
content={tab.content}
|
||||
languageId={tab.languageId}
|
||||
wordWrap={tab.wordWrap}
|
||||
saving={tab.savingState === "saving"}
|
||||
saveError={tab.saveError}
|
||||
hotkeyScheme={settings.hotkeyScheme}
|
||||
keyBindings={settings.keyBindings}
|
||||
onContentChange={handleContentChange}
|
||||
onLanguageChange={(lang) => editorTabStore.setLanguage(tabId, lang)}
|
||||
onToggleWordWrap={() => {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (current) editorTabStore.setWordWrap(tabId, !current.wordWrap);
|
||||
}}
|
||||
onSave={() => { void handleSave(); }}
|
||||
onRequestClose={() => onRequestClose(tabId)}
|
||||
onDockToMain={() => onDock(tabId)}
|
||||
initialViewState={tab.viewState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
const { t } = useI18n();
|
||||
const { activeTabId, setActiveTabId, closeWindow, setWindowTitle } = useEditorWindowRuntime();
|
||||
const { onWindowCommandCloseRequested } = useWindowControls();
|
||||
const tabs = useEditorTabChromeList();
|
||||
const closeInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||
const activeTab = useEditorTab(activeTabId ?? "");
|
||||
const activeDirty = useEditorTabDirty(activeTabId ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTabId && editorTabStore.getTab(activeTabId)) return;
|
||||
if (tabs.length > 0) setActiveTabId(tabs[tabs.length - 1].id);
|
||||
}, [activeTabId, setActiveTabId, tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
const name = activeTab?.fileName;
|
||||
if (!name) {
|
||||
void setWindowTitle(t("sftp.editor.windowTitle"));
|
||||
return;
|
||||
}
|
||||
void setWindowTitle(`${activeDirty ? "* " : ""}${name}`);
|
||||
}, [activeDirty, activeTab?.fileName, setWindowTitle, t]);
|
||||
|
||||
const saveTab = useCallback(async (tabId: EditorTabId): Promise<boolean> => {
|
||||
const result = await saveDetachedEditorTab(tabId);
|
||||
if (result.ok) return true;
|
||||
toast.error(result.error ?? t("sftp.editor.saveFailed"), "SFTP");
|
||||
return false;
|
||||
}, [t]);
|
||||
|
||||
const closeTab = useCallback(async (tabId: EditorTabId, force = false): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(tabId);
|
||||
if (!tab) return true;
|
||||
if (!force && tabIsDirty(tab)) {
|
||||
const choice = await promptUnsavedChanges(tab.fileName);
|
||||
if (choice === "cancel") return false;
|
||||
if (choice === "save") {
|
||||
const ok = await saveTab(tabId);
|
||||
if (!ok) return false;
|
||||
}
|
||||
}
|
||||
editorTabStore.close(tabId);
|
||||
reportDetachedEditorTabsClosed([tabId]);
|
||||
return true;
|
||||
}, [saveTab]);
|
||||
|
||||
const closeTabAndMaybeWindow = useCallback(async (tabId: EditorTabId, force = false): Promise<boolean> => {
|
||||
if (closeInFlightRef.current) return closeInFlightRef.current;
|
||||
const task = (async () => {
|
||||
const ok = await closeTab(tabId, force);
|
||||
if (!ok) return false;
|
||||
const remaining = editorTabStore.getTabs();
|
||||
if (remaining.length === 0) {
|
||||
await closeWindow();
|
||||
return true;
|
||||
}
|
||||
if (activeTabId === tabId) {
|
||||
setActiveTabId(remaining[remaining.length - 1]?.id ?? null);
|
||||
}
|
||||
return true;
|
||||
})().finally(() => {
|
||||
closeInFlightRef.current = null;
|
||||
});
|
||||
closeInFlightRef.current = task;
|
||||
return task;
|
||||
}, [activeTabId, closeTab, closeWindow, setActiveTabId]);
|
||||
|
||||
const handleDock = useCallback(async (tabId: EditorTabId) => {
|
||||
const result = await dockDetachedEditorTab(tabId);
|
||||
if (!result.success) {
|
||||
toast.error(result.error ?? t("sftp.editor.dockFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
editorTabStore.close(tabId);
|
||||
const remaining = editorTabStore.getTabs();
|
||||
if (remaining.length === 0) {
|
||||
await closeWindow();
|
||||
return;
|
||||
}
|
||||
if (activeTabId === tabId) {
|
||||
setActiveTabId(remaining[remaining.length - 1]?.id ?? null);
|
||||
}
|
||||
}, [activeTabId, closeWindow, setActiveTabId, t]);
|
||||
|
||||
const handleWindowClose = useCallback(async () => {
|
||||
// Native close owns confirmation and blocks new transfers until it settles.
|
||||
await closeWindow();
|
||||
}, [closeWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
return onWindowCommandCloseRequested(() => {
|
||||
if (activeTabId) void closeTabAndMaybeWindow(activeTabId);
|
||||
else void handleWindowClose();
|
||||
});
|
||||
}, [activeTabId, closeTabAndMaybeWindow, handleWindowClose, onWindowCommandCloseRequested]);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeEditorWindowDirtyCheck(() => {
|
||||
const hasDirty = editorTabStore.getTabs().some((tab) => tabIsDirty(tab));
|
||||
if (hasDirty) toast.warning(t("sftp.editor.quitBlockedByDirty"), "SFTP");
|
||||
return hasDirty;
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeEditorWindowCloseTabs(async (payload) => {
|
||||
const closedIds: string[] = [];
|
||||
let cancelled = false;
|
||||
for (const editorId of payload.editorIds ?? []) {
|
||||
const ok = await closeTab(editorId, payload.force === true);
|
||||
if (!ok) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
closedIds.push(editorId);
|
||||
}
|
||||
reportEditorWindowCloseTabsResult({
|
||||
requestId: payload.requestId,
|
||||
cancelled,
|
||||
closedIds,
|
||||
});
|
||||
if (!cancelled && editorTabStore.getTabs().length === 0) {
|
||||
await closeWindow();
|
||||
} else if (activeTabId && !editorTabStore.getTab(activeTabId)) {
|
||||
setActiveTabId(editorTabStore.getTabs()[0]?.id ?? null);
|
||||
}
|
||||
});
|
||||
}, [activeTabId, closeTab, closeWindow, setActiveTabId]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<div className="flex h-10 shrink-0 items-center border-b border-border/60 app-drag">
|
||||
{isMac ? <div className="w-[72px] shrink-0" /> : null}
|
||||
<div className="px-3 text-xs font-semibold text-muted-foreground app-no-drag">
|
||||
{t("sftp.editor.windowTitle")}
|
||||
</div>
|
||||
<EditorWindowTabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
onSelect={setActiveTabId}
|
||||
onClose={(tabId) => { void closeTabAndMaybeWindow(tabId); }}
|
||||
/>
|
||||
<EditorWindowControls onClose={() => { void handleWindowClose(); }} />
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{tabs.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("sftp.editor.windowEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
tabs.map((tab) => (
|
||||
<EditorWindowPaneHost
|
||||
key={tab.id}
|
||||
tabId={tab.id}
|
||||
isVisible={tab.id === activeTabId}
|
||||
settings={settings}
|
||||
onRequestClose={(id) => { void closeTabAndMaybeWindow(id); }}
|
||||
onDock={(id) => { void handleDock(id); }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditorWindowPage({
|
||||
settings,
|
||||
}: {
|
||||
settings: SettingsState;
|
||||
allowEdit?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<UnsavedChangesProvider>
|
||||
{() => <EditorWindowPageInner settings={settings} />}
|
||||
</UnsavedChangesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { FileCode, FileText, X } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { useEditorTabDirty, type EditorTabChrome } from "../../application/state/editorTabStore";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i;
|
||||
|
||||
export interface EditorWindowTabBarProps {
|
||||
tabs: readonly EditorTabChrome[];
|
||||
activeTabId: string | null;
|
||||
onSelect: (tabId: string) => void;
|
||||
onClose: (tabId: string) => void;
|
||||
}
|
||||
|
||||
export const EditorWindowTabBar: React.FC<EditorWindowTabBarProps> = ({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onSelect,
|
||||
onClose,
|
||||
}) => {
|
||||
const fileNameCounts = new Map<string, number>();
|
||||
for (const tab of tabs) {
|
||||
fileNameCounts.set(tab.fileName, (fileNameCounts.get(tab.fileName) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-8 min-w-0 flex-1 items-end gap-0.5 overflow-x-auto px-1">
|
||||
{tabs.map((tab) => (
|
||||
<EditorWindowTab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
suffix={
|
||||
(fileNameCounts.get(tab.fileName) ?? 0) > 1
|
||||
? ` · ${tab.hostLabel || tab.hostId}`
|
||||
: ""
|
||||
}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EditorWindowTab: React.FC<{
|
||||
tab: EditorTabChrome;
|
||||
isActive: boolean;
|
||||
suffix: string;
|
||||
onSelect: (tabId: string) => void;
|
||||
onClose: (tabId: string) => void;
|
||||
}> = ({ tab, isActive, suffix, onSelect, onClose }) => {
|
||||
const dirty = useEditorTabDirty(tab.id);
|
||||
const FileIcon = CODE_EXTENSIONS_RE.test(tab.fileName) ? FileCode : FileText;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(tab.id)}
|
||||
className={cn(
|
||||
"app-no-drag group relative flex h-7 max-w-[220px] min-w-[120px] items-center gap-1.5 rounded-t-md px-2.5 text-xs font-semibold",
|
||||
isActive ? "bg-background text-foreground" : "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<FileIcon size={13} className="shrink-0" />
|
||||
<span className="truncate">
|
||||
{dirty && <span className="mr-0.5 text-primary">●</span>}
|
||||
{tab.fileName}
|
||||
{suffix}
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className="ml-auto rounded-full p-0.5 opacity-0 hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClose(tab.id);
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { ContextKeyExpr } = require('monaco-editor/esm/vs/platform/contextkey/common/contextkey.js');
|
||||
const { KeybindingResolver } = require('monaco-editor/esm/vs/platform/keybinding/common/keybindingResolver.js');
|
||||
import { registerTextEditorCommand } from './TextEditorPane.tsx';
|
||||
|
||||
test('save, close, find and paste resolve to the focused editor when two editors share a window', () => {
|
||||
const commands = new Map<string, Parameters<typeof registerTextEditorCommand>[2]>();
|
||||
const bindings: Array<{ command: string; commandArgs: undefined; chords: string[]; when: ReturnType<typeof ContextKeyExpr.deserialize>; isDefault: boolean; bubble: boolean }> = [];
|
||||
const calls: string[] = [];
|
||||
const actions = ['save', 'close', 'find', 'paste'];
|
||||
// Match the native regression: beta was created first, alpha last, then
|
||||
// beta was clicked. Unscoped Monaco commands incorrectly chose alpha.
|
||||
for (const editorId of ['vs.editor.ICodeEditor:beta', 'vs.editor.ICodeEditor:alpha']) {
|
||||
const editor = {
|
||||
getId: () => editorId,
|
||||
addCommand(keybinding: number, handler: Parameters<typeof registerTextEditorCommand>[2], context?: string) {
|
||||
const command = `${editorId}:${keybinding}`;
|
||||
commands.set(command, handler);
|
||||
bindings.push({ command, commandArgs: undefined, chords: [String(keybinding)], when: ContextKeyExpr.deserialize(context), isDefault: false, bubble: false });
|
||||
return command;
|
||||
},
|
||||
};
|
||||
actions.forEach((action, keybinding) => registerTextEditorCommand(editor, keybinding, () => { calls.push(`${editorId}:${action}`); }));
|
||||
}
|
||||
const resolver = new KeybindingResolver([], bindings, () => {});
|
||||
function press(editorId: string | undefined, keybinding: number) {
|
||||
const result = resolver.resolve({ getValue: (key: string) => key === 'editorId' ? editorId : undefined }, [], String(keybinding)) as { kind: number; commandId?: string };
|
||||
if (result.kind === 2) commands.get(result.commandId!)!(null as never);
|
||||
return result.kind;
|
||||
}
|
||||
for (const editorId of ['vs.editor.ICodeEditor:beta', 'vs.editor.ICodeEditor:alpha', 'vs.editor.ICodeEditor:beta']) {
|
||||
actions.forEach((action, keybinding) => {
|
||||
const before = calls.length;
|
||||
assert.equal(press(editorId, keybinding), 2);
|
||||
assert.deepEqual(calls.slice(before), [`${editorId}:${action}`]);
|
||||
});
|
||||
}
|
||||
const before = calls.length;
|
||||
actions.forEach((_, keybinding) => assert.equal(press(undefined, keybinding), 0));
|
||||
assert.equal(calls.length, before, 'no hidden editor handles keys outside editor context');
|
||||
});
|
||||
|
||||
test('every TextEditorPane Monaco shortcut uses the scoped registration', () => {
|
||||
const source = readFileSync(new URL('./TextEditorPane.tsx', import.meta.url), 'utf8');
|
||||
const mount = source.slice(source.indexOf('const handleEditorMount:'), source.indexOf('// Capture-phase close-tab'));
|
||||
assert.equal(mount.match(/registerTextEditorCommand\(editor,/g)?.length, 4);
|
||||
assert.doesNotMatch(mount, /editor\.addCommand\(/);
|
||||
});
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
PanelLeft,
|
||||
Search,
|
||||
SquareArrowOutUpRight,
|
||||
WrapText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
@@ -93,8 +95,8 @@ export interface TextEditorPaneProps {
|
||||
saveError: string | null;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
/** Layout mode — affects header chrome (modal shows close+maximize; tab-form only shows content controls since tab has its own close). */
|
||||
chrome: 'modal' | 'tab';
|
||||
/** Layout mode — modal has close+maximize+popout; tab has popout; window has dock. */
|
||||
chrome: 'modal' | 'tab' | 'window';
|
||||
/** Optional secondary label shown next to the filename in muted text — used by the tab form to display `host:remotePath`. */
|
||||
subtitle?: string;
|
||||
onContentChange: (content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => void;
|
||||
@@ -103,9 +105,20 @@ export interface TextEditorPaneProps {
|
||||
onSave: () => void;
|
||||
onRequestClose?: () => void; // modal only
|
||||
onPromoteToTab?: () => void; // modal only — omit to hide the maximize button
|
||||
onPopOut?: () => void; // modal/tab — open in the dedicated editor window
|
||||
onDockToMain?: () => void; // window chrome — dock current tab back to the main window
|
||||
initialViewState?: Monaco.editor.ICodeEditorViewState | null;
|
||||
}
|
||||
|
||||
/** Monaco commands share a window-level resolver; bind each to its owning editor. */
|
||||
export function registerTextEditorCommand(
|
||||
editor: Pick<Monaco.editor.IStandaloneCodeEditor, 'addCommand' | 'getId'>,
|
||||
keybinding: number,
|
||||
handler: Monaco.editor.ICommandHandler,
|
||||
): string | null {
|
||||
return editor.addCommand(keybinding, handler, `editorId == '${editor.getId()}'`);
|
||||
}
|
||||
|
||||
export const isTextEditorReadOnly = ({ saving }: { saving: boolean }): boolean => saving;
|
||||
|
||||
export const canPromoteTextEditor = ({ saving }: { saving: boolean }): boolean => !saving;
|
||||
@@ -174,6 +187,8 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
|
||||
onSave,
|
||||
onRequestClose,
|
||||
onPromoteToTab,
|
||||
onPopOut,
|
||||
onDockToMain,
|
||||
initialViewState,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -284,7 +299,7 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
|
||||
if (initialViewState) editor.restoreViewState(initialViewState);
|
||||
|
||||
// Add save shortcut - use ref to avoid stale closure
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
registerTextEditorCommand(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSaveRef.current();
|
||||
});
|
||||
|
||||
@@ -292,20 +307,20 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
|
||||
// Pane's root div also tries to handle this, but Monaco's internal
|
||||
// key-event dispatcher fires first for focused editor keystrokes, so
|
||||
// registering the command here is the reliable path.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => {
|
||||
registerTextEditorCommand(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => {
|
||||
if (!closeTabCommandWEnabledRef.current) return;
|
||||
handleCloseRef.current?.();
|
||||
});
|
||||
|
||||
// Add find shortcut (Ctrl+F / Cmd+F)
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
|
||||
registerTextEditorCommand(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
// When focus is in the find/replace widget, paste into that input instead of the body.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
registerTextEditorCommand(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
void pasteForMonacoEditorCommand({
|
||||
activeElement: document.activeElement,
|
||||
readClipboardText: () => readClipboardTextRef.current(),
|
||||
@@ -439,6 +454,42 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{onPopOut && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onPopOut}
|
||||
disabled={saving}
|
||||
aria-label={t('sftp.editor.popOut')}
|
||||
>
|
||||
<SquareArrowOutUpRight size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sftp.editor.popOut')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{chrome === 'window' && onDockToMain && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onDockToMain}
|
||||
disabled={saving}
|
||||
aria-label={t('sftp.editor.dock')}
|
||||
>
|
||||
<PanelLeft size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sftp.editor.dock')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Close button — modal chrome only */}
|
||||
{chrome === 'modal' && onRequestClose && (
|
||||
<Button
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface TextEditorTabViewProps {
|
||||
/** Routed into Monaco's Cmd/Ctrl+W command so closing the editor tab works
|
||||
* even when focus is inside the editor (Monaco otherwise swallows the event). */
|
||||
onRequestClose: (tabId: EditorTabId) => void;
|
||||
onPopOut?: (tabId: EditorTabId) => void;
|
||||
}
|
||||
|
||||
export function getTextEditorTabShellStyle(isVisible: boolean, hostTreeLayoutWidth: number): React.CSSProperties {
|
||||
@@ -42,6 +43,7 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
keyBindings,
|
||||
hostById,
|
||||
onRequestClose,
|
||||
onPopOut,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const tab = useEditorTab(tabId);
|
||||
@@ -124,6 +126,7 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
onToggleWordWrap={handleToggleWordWrap}
|
||||
onSave={handleSave}
|
||||
initialViewState={tab.viewState}
|
||||
onPopOut={onPopOut ? () => onPopOut(tabId) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -102,6 +102,7 @@ interface SftpOverlaysProps {
|
||||
handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
|
||||
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
onPopOut?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
}
|
||||
|
||||
@@ -145,6 +146,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
onPromoteToTab,
|
||||
onPopOut,
|
||||
onRequestTerminalFocus,
|
||||
}) => {
|
||||
const textEditorFileName = textEditorTarget?.file.name || "";
|
||||
@@ -269,6 +271,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
onPopOut={onPopOut}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isNavigableDirectory } from "../utils";
|
||||
import { resolveDownloadSourceSnapshot } from "../../../application/state/sftp/downloadSourceSnapshot";
|
||||
import { reportSftpUploadResults } from "../reportSftpUploadResults";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import { popOutEditorTab } from "../../../application/state/editorWindowClient";
|
||||
import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore";
|
||||
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
||||
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "./useSftpViewFileOps.types";
|
||||
@@ -27,6 +28,15 @@ const LOCAL_BLOB_DOWNLOAD_CONCURRENCY = 1;
|
||||
*/
|
||||
const MULTI_SELECT_ROOT_DOWNLOAD_CONCURRENCY = DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
|
||||
|
||||
const promoteModalSnapshot = (snapshot: Parameters<typeof editorTabStore.promoteFromModal>[0]) => {
|
||||
try {
|
||||
return editorTabStore.promoteFromModal(snapshot);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to transfer editor", "SFTP");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const useSftpViewFileOps = ({
|
||||
sftpRef,
|
||||
behaviorRef,
|
||||
@@ -232,7 +242,7 @@ export const useSftpViewFileOps = ({
|
||||
const connection = pane.connection;
|
||||
if (!connection || !target.hostId) return;
|
||||
|
||||
const editorId = editorTabStore.promoteFromModal({
|
||||
const editorId = promoteModalSnapshot({
|
||||
sessionId: connection.id,
|
||||
sftpTabId: pane.id,
|
||||
hostId: target.hostId,
|
||||
@@ -244,6 +254,16 @@ export const useSftpViewFileOps = ({
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
});
|
||||
if (!editorId) return;
|
||||
if (editorTabStore.getTab(editorId)?.placement === "window") {
|
||||
void popOutEditorTab(editorId).then((ok) => {
|
||||
if (!ok) return;
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
});
|
||||
return;
|
||||
}
|
||||
activeTabStore.setActiveTabId(toEditorTabId(editorId));
|
||||
// Close the modal
|
||||
setShowTextEditor(false);
|
||||
@@ -251,6 +271,34 @@ export const useSftpViewFileOps = ({
|
||||
setTextEditorContent("");
|
||||
}, [sftpRef]);
|
||||
|
||||
const handlePopOut = useCallback((snapshot: TextEditorModalSnapshot) => {
|
||||
const target = textEditorTargetRef.current;
|
||||
if (!target) return;
|
||||
const pane = target.side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
const connection = pane.connection;
|
||||
if (!connection || !target.hostId) return;
|
||||
|
||||
const editorId = promoteModalSnapshot({
|
||||
sessionId: connection.id,
|
||||
sftpTabId: pane.id,
|
||||
hostId: target.hostId,
|
||||
remotePath: target.fullPath,
|
||||
fileName: target.file.name,
|
||||
languageId: snapshot.languageId || getLanguageId(target.file.name),
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
});
|
||||
if (!editorId) return;
|
||||
void popOutEditorTab(editorId).then((ok) => {
|
||||
if (!ok) return;
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
});
|
||||
}, [sftpRef]);
|
||||
|
||||
const onEditFileLeft = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("left", file, fullPath),
|
||||
[handleEditFileForSide],
|
||||
@@ -808,6 +856,7 @@ export const useSftpViewFileOps = ({
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab: handlePromoteToTab,
|
||||
onPopOut: handlePopOut,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
onEditPermissionsLeft,
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface UseSftpViewFileOpsResult {
|
||||
>;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
onPromoteToTab: (snapshot: TextEditorModalSnapshot) => void;
|
||||
onPopOut: (snapshot: TextEditorModalSnapshot) => void;
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { keepOnlyActivePaneSelections } from "./selectionScope";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
|
||||
import { confirmCloseOwnedEditors } from "../../../application/state/editorWindowClient";
|
||||
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { requireCopyToOtherPaneTarget } from "../copyToOtherPane";
|
||||
@@ -182,7 +183,7 @@ export const useSftpViewPaneActions = ({
|
||||
throw new Error(tab?.saveError ?? "Save failed");
|
||||
}
|
||||
};
|
||||
return editorTabStore.confirmCloseByOwner(
|
||||
return confirmCloseOwnedEditors(
|
||||
{ sessionId: pane.connection?.id, sftpTabId: pane.id },
|
||||
choice,
|
||||
saveTab,
|
||||
|
||||
@@ -233,6 +233,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
setFileOpenerTarget: fileOps.setFileOpenerTarget,
|
||||
handleSaveTextFile: fileOps.handleSaveTextFile,
|
||||
onPromoteToTab: fileOps.onPromoteToTab,
|
||||
onPopOut: fileOps.onPopOut,
|
||||
handleFileOpenerSelect: fileOps.handleFileOpenerSelect,
|
||||
handleSelectSystemApp: fileOps.handleSelectSystemApp,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
|
||||
import { confirmCloseOwnedEditors } from "../../../application/state/editorWindowClient";
|
||||
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
|
||||
import {
|
||||
getSftpTabDuplicateRequest,
|
||||
@@ -84,7 +85,7 @@ export const useSftpViewTabs = ({ sftp, sftpRef, hosts = [] }: UseSftpViewTabsPa
|
||||
throw new Error(tab?.saveError ?? "Save failed");
|
||||
}
|
||||
};
|
||||
return editorTabStore.confirmCloseByOwner(
|
||||
return confirmCloseOwnedEditors(
|
||||
owner,
|
||||
choice,
|
||||
saveTab,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Copy, FileCode, FileText, LayoutGrid, Minus, Server, Square, Terminal, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import { Copy, FileCode, FileText, LayoutGrid, Minus, Server, Square, SquareArrowOutUpRight, Terminal, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, useIsTabActive } from '../../application/state/activeTabStore';
|
||||
import {
|
||||
@@ -578,6 +578,8 @@ interface EditorTopTabProps {
|
||||
host: Host | undefined;
|
||||
suffix: string;
|
||||
onRequestCloseEditorTab: (editorTabId: string) => void;
|
||||
onPopOutEditorTab?: (editorTabId: string) => void;
|
||||
onFocusEditorWindowTab?: (editorTabId: string) => void;
|
||||
isBeingDragged: boolean;
|
||||
isDraggingForReorder: boolean;
|
||||
shiftStyle: React.CSSProperties;
|
||||
@@ -598,6 +600,8 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
host,
|
||||
suffix,
|
||||
onRequestCloseEditorTab,
|
||||
onPopOutEditorTab,
|
||||
onFocusEditorWindowTab,
|
||||
isBeingDragged,
|
||||
isDraggingForReorder,
|
||||
shiftStyle,
|
||||
@@ -611,22 +615,30 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
tabAnimationClass,
|
||||
shortcutNumber,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsTabActive(tabId);
|
||||
// Dirty is store-driven so App/TopTabs structure can stay presence-only.
|
||||
const dirty = useEditorTabDirty(editorTab.id);
|
||||
const isDetached = editorTab.placement === "window";
|
||||
const tooltip = `${host?.label ?? editorTab.hostId}@${host?.hostname ?? ''}:${editorTab.remotePath}`;
|
||||
const FileIcon = CODE_EXTENSIONS_RE.test(editorTab.fileName) ? FileCode : FileText;
|
||||
const handleClick = useCallback(() => {
|
||||
if (isDetached) {
|
||||
onFocusEditorWindowTab?.(editorTab.id);
|
||||
return;
|
||||
}
|
||||
activeTabStore.setActiveTabId(tabId);
|
||||
}, [tabId]);
|
||||
}, [editorTab.id, isDetached, onFocusEditorWindowTab, tabId]);
|
||||
const handleClose = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onRequestCloseEditorTab(editorTab.id);
|
||||
}, [editorTab.id, onRequestCloseEditorTab]);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
data-tab-id={tabId}
|
||||
data-tab-type="editor"
|
||||
@@ -695,6 +707,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
{editorTab.fileName}
|
||||
{suffix && <span className="ml-1 text-muted-foreground">{suffix}</span>}
|
||||
</span>
|
||||
{isDetached && <SquareArrowOutUpRight size={11} className="shrink-0 opacity-70" />}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
@@ -704,9 +717,29 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
<ContextMenuContent className="w-48">
|
||||
{!isDetached && onPopOutEditorTab && (
|
||||
<ContextMenuItem onClick={() => onPopOutEditorTab(editorTab.id)}>
|
||||
<SquareArrowOutUpRight size={14} className="mr-2" />
|
||||
{t('sftp.editor.popOut')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{isDetached && onFocusEditorWindowTab && (
|
||||
<ContextMenuItem onClick={() => onFocusEditorWindowTab(editorTab.id)}>
|
||||
<SquareArrowOutUpRight size={14} className="mr-2" />
|
||||
{t('sftp.editor.focusWindow')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onRequestCloseEditorTab(editorTab.id)}>
|
||||
<X size={14} className="mr-2" />
|
||||
{t('sftp.editor.close')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
});
|
||||
EditorTopTab.displayName = 'EditorTopTab';
|
||||
|
||||
@@ -13,6 +13,7 @@ test('terminal windows do not throttle work while hidden or unfocused', () => {
|
||||
const windowSources = [
|
||||
'bridges/windowManager/mainWindow.cjs',
|
||||
'bridges/windowManager/terminalPopupWindow.cjs',
|
||||
'bridges/windowManager/editorWindow.cjs',
|
||||
];
|
||||
|
||||
for (const relativePath of windowSources) {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { generateKeyPairSync } = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
@@ -25,7 +26,6 @@ const path = require("node:path");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
|
||||
const { Server } = require("ssh2");
|
||||
const keygen = require("ssh2/lib/keygen.js");
|
||||
|
||||
const pool = require("./sshConnectionPool.cjs");
|
||||
const kiHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
@@ -201,62 +201,62 @@ function makeSftpHandlers(files, dirs) {
|
||||
|
||||
function startOtpOnlyServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
keygen.generateKeyPair("ed25519", (err, keys) => {
|
||||
if (err) return reject(err);
|
||||
const files = new Map();
|
||||
const dirs = new Set(["/", "/home", "/home/otpuser"]);
|
||||
const handlers = makeSftpHandlers(files, dirs);
|
||||
let keyboardInteractiveRounds = 0;
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const files = new Map();
|
||||
const dirs = new Set(["/", "/home", "/home/otpuser"]);
|
||||
const handlers = makeSftpHandlers(files, dirs);
|
||||
let keyboardInteractiveRounds = 0;
|
||||
|
||||
const sshServer = new Server({ hostKeys: [keys.private] }, (client) => {
|
||||
client.on("authentication", (ctx) => {
|
||||
if (ctx.method === "keyboard-interactive") {
|
||||
keyboardInteractiveRounds += 1;
|
||||
ctx.prompt(
|
||||
[{ prompt: "Verification code (OTP):", echo: false }],
|
||||
"OTP required",
|
||||
"",
|
||||
(answers) => {
|
||||
if (answers && answers[0] === OTP_CODE) ctx.accept();
|
||||
else ctx.reject(["keyboard-interactive"]);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The server refuses everything else: OTP is the only way in.
|
||||
ctx.reject(["keyboard-interactive"]);
|
||||
});
|
||||
client.on("ready", () => {
|
||||
client.on("session", (accept) => {
|
||||
const session = accept();
|
||||
session.on("sftp", (acceptChannel) => {
|
||||
const sftp = acceptChannel();
|
||||
handlers.attach(sftp);
|
||||
});
|
||||
const sshServer = new Server({
|
||||
hostKeys: [privateKey.export({ type: "pkcs1", format: "pem" })],
|
||||
}, (client) => {
|
||||
client.on("authentication", (ctx) => {
|
||||
if (ctx.method === "keyboard-interactive") {
|
||||
keyboardInteractiveRounds += 1;
|
||||
ctx.prompt(
|
||||
[{ prompt: "Verification code (OTP):", echo: false }],
|
||||
"OTP required",
|
||||
"",
|
||||
(answers) => {
|
||||
if (answers && answers[0] === OTP_CODE) ctx.accept();
|
||||
else ctx.reject(["keyboard-interactive"]);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The server refuses everything else: OTP is the only way in.
|
||||
ctx.reject(["keyboard-interactive"]);
|
||||
});
|
||||
client.on("ready", () => {
|
||||
client.on("session", (accept) => {
|
||||
const session = accept();
|
||||
session.on("sftp", (acceptChannel) => {
|
||||
const sftp = acceptChannel();
|
||||
handlers.attach(sftp);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
sshServer.injectSocket(socket);
|
||||
});
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve({
|
||||
port: server.address().port,
|
||||
files,
|
||||
dirs,
|
||||
getKeyboardInteractiveRounds: () => keyboardInteractiveRounds,
|
||||
close: () => new Promise((done) => {
|
||||
// Include clients still waiting for authentication, before registration.
|
||||
for (const socket of sockets) socket.destroy();
|
||||
sshServer.close?.();
|
||||
server.close(() => done());
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
sshServer.injectSocket(socket);
|
||||
});
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve({
|
||||
port: server.address().port,
|
||||
files,
|
||||
dirs,
|
||||
getKeyboardInteractiveRounds: () => keyboardInteractiveRounds,
|
||||
close: () => new Promise((done) => {
|
||||
// Include clients still waiting for authentication, before registration.
|
||||
for (const socket of sockets) socket.destroy();
|
||||
sshServer.close?.();
|
||||
server.close(() => done());
|
||||
}),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -946,6 +946,8 @@ const mainWindowApi = createMainWindowApi({
|
||||
createAppWindowOpenHandler,
|
||||
attachOAuthLoadingOverlay,
|
||||
queryDirtyEditors: (...args) => require("./dirtyEditorGuard.cjs").queryDirtyEditors(...args),
|
||||
hasEditorTabsForSource: (webContents) => editorWindowApi.hasEditorTabsForSource(webContents),
|
||||
closeEditorTabsForSource: (...args) => editorWindowApi.closeEditorTabsForSource(...args),
|
||||
registerWindowHandlers,
|
||||
requestWindowCommandClose,
|
||||
shouldCloseWindowFromInput,
|
||||
@@ -1039,6 +1041,36 @@ const {
|
||||
getTerminalPopupWindows,
|
||||
} = terminalPopupWindowApi;
|
||||
|
||||
const { createEditorWindowApi } = require("./windowManager/editorWindow.cjs");
|
||||
const editorWindowApi = createEditorWindowApi({
|
||||
get mainWindow() { return mainWindow; },
|
||||
get currentTheme() { return currentTheme; },
|
||||
get isQuitting() { return isQuitting; },
|
||||
V8_CACHE_OPTIONS,
|
||||
__dirname,
|
||||
resolveFrontendBackgroundColor,
|
||||
createExternalOnlyWindowOpenHandler,
|
||||
getDevRendererBaseUrl,
|
||||
applyWindowOpacityToWindow,
|
||||
showAndFocusWindow,
|
||||
resolveSettingsWindowBounds,
|
||||
registerAppContentWindow,
|
||||
unregisterAppContentWindow,
|
||||
notifyAppContentWindowClosed,
|
||||
queryDirtyEditors: (...args) => require("./dirtyEditorGuard.cjs").queryDirtyEditors(...args),
|
||||
});
|
||||
const {
|
||||
openEditorWindow,
|
||||
focusEditorTab,
|
||||
closeEditorTabs: closeEditorWindowTabs,
|
||||
saveEditorTab: saveEditorWindowTab,
|
||||
dockEditorTab,
|
||||
reportEditorDirty,
|
||||
reportEditorTabsClosed,
|
||||
remapEditorSession,
|
||||
getEditorWindow,
|
||||
} = editorWindowApi;
|
||||
|
||||
/**
|
||||
* Register window control IPC handlers (only once)
|
||||
*/
|
||||
@@ -1287,6 +1319,16 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage, options = {}
|
||||
// ignore
|
||||
}
|
||||
// 只有主窗口/设置窗口会接收 command-close;其他 BrowserWindow 直接关闭。
|
||||
// 编辑窗口例外:Cmd+W 加速器应走渲染层(关闭活动标签页),而不是原生关闭
|
||||
// 整个窗口并确认所有标签页。
|
||||
if (browserWindow && browserWindow === getEditorWindow()) {
|
||||
if (event?.triggeredByAccelerator === false) {
|
||||
closeBrowserWindow(browserWindow);
|
||||
return;
|
||||
}
|
||||
requestWindowCommandClose(browserWindow);
|
||||
return;
|
||||
}
|
||||
if (browserWindow && !isMainWindow(browserWindow) && browserWindow !== settingsWindow) {
|
||||
closeBrowserWindow(browserWindow);
|
||||
return;
|
||||
@@ -1483,6 +1525,15 @@ module.exports = {
|
||||
openTerminalPopupWindow,
|
||||
closeTerminalPopupWindow,
|
||||
getTerminalPopupWindows,
|
||||
openEditorWindow,
|
||||
focusEditorTab,
|
||||
closeEditorWindowTabs,
|
||||
saveEditorWindowTab,
|
||||
dockEditorTab,
|
||||
reportEditorDirty,
|
||||
reportEditorTabsClosed,
|
||||
remapEditorSession,
|
||||
getEditorWindow,
|
||||
prewarmSettingsWindow,
|
||||
buildAppMenu,
|
||||
getCurrentLanguage,
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const {
|
||||
windowsFramelessContentChromeOptions,
|
||||
} = require("./windowsWindowChrome.cjs");
|
||||
|
||||
const EDITOR_WIDTH = 1100;
|
||||
const EDITOR_HEIGHT = 760;
|
||||
const CLOSE_TABS_PROMPT_TIMEOUT_MS = 120000;
|
||||
const CLOSE_TABS_FORCE_TIMEOUT_MS = 8000;
|
||||
const SAVE_TIMEOUT_MS = 30000;
|
||||
const DOCK_TIMEOUT_MS = 8000;
|
||||
const OPEN_TAB_TIMEOUT_MS = 30000;
|
||||
const MAX_EDITOR_CONTENT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function isLiveWindow(win) {
|
||||
return Boolean(win && typeof win.isDestroyed === "function" && !win.isDestroyed());
|
||||
}
|
||||
|
||||
function sanitizeEditorSnapshot(payload) {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const editorId = typeof payload.editorId === "string" ? payload.editorId.trim() : "";
|
||||
const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : "";
|
||||
const sftpTabId = typeof payload.sftpTabId === "string" ? payload.sftpTabId : "";
|
||||
const hostId = typeof payload.hostId === "string" ? payload.hostId : "";
|
||||
const remotePath = typeof payload.remotePath === "string" ? payload.remotePath : "";
|
||||
const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
|
||||
if (!editorId || !sessionId || !sftpTabId || !hostId || !remotePath || !fileName) {
|
||||
return null;
|
||||
}
|
||||
const content = typeof payload.content === "string" ? payload.content : "";
|
||||
const baselineContent = typeof payload.baselineContent === "string" ? payload.baselineContent : "";
|
||||
try {
|
||||
if (Buffer.byteLength(content, "utf8") > MAX_EDITOR_CONTENT_BYTES) return null;
|
||||
if (Buffer.byteLength(baselineContent, "utf8") > MAX_EDITOR_CONTENT_BYTES) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
editorId,
|
||||
sessionId,
|
||||
sftpTabId,
|
||||
hostId,
|
||||
hostLabel: typeof payload.hostLabel === "string" ? payload.hostLabel : undefined,
|
||||
remotePath,
|
||||
fileName,
|
||||
languageId: typeof payload.languageId === "string" && payload.languageId
|
||||
? payload.languageId
|
||||
: "plaintext",
|
||||
content,
|
||||
baselineContent,
|
||||
wordWrap: payload.wordWrap === true,
|
||||
viewState: payload.viewState && typeof payload.viewState === "object" ? payload.viewState : null,
|
||||
};
|
||||
}
|
||||
|
||||
function createEditorWindowApi(ctx) {
|
||||
let editorWindow = null;
|
||||
let editorWindowLoaded = null;
|
||||
let nativeClosePendingWindow = null;
|
||||
const tabSources = new Map();
|
||||
|
||||
function getEditorWindow() {
|
||||
if (!isLiveWindow(editorWindow)) {
|
||||
editorWindow = null;
|
||||
return null;
|
||||
}
|
||||
return editorWindow;
|
||||
}
|
||||
|
||||
function hasEditorTabsForSource(webContents) {
|
||||
return Array.from(tabSources.values()).some((owner) => owner.webContentsId === webContents?.id);
|
||||
}
|
||||
|
||||
async function closeEditorTabsForSource(electronModule, webContents) {
|
||||
const editorIds = Array.from(tabSources).filter(([, owner]) => owner.webContentsId === webContents?.id).map(([id]) => id);
|
||||
return closeEditorTabs(electronModule, { editorIds });
|
||||
}
|
||||
|
||||
function resolveSourceWebContents(electronModule, editorId) {
|
||||
const id = tabSources.get(editorId)?.webContentsId;
|
||||
if (!Number.isFinite(id)) return null;
|
||||
try {
|
||||
const wc = electronModule.webContents.fromId(id);
|
||||
if (!wc || wc.isDestroyed?.()) return null;
|
||||
return wc;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function invokeWebContents(electronModule, webContents, sendChannel, replyChannel, payload, timeoutMs) {
|
||||
const ipcMain = electronModule.ipcMain;
|
||||
if (!ipcMain || !webContents || webContents.isDestroyed?.()) {
|
||||
return Promise.resolve({ success: false, error: "Target window is gone" });
|
||||
}
|
||||
const requestId = payload.requestId || randomUUID();
|
||||
const message = { ...payload, requestId };
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timeoutId = null;
|
||||
const settle = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
ipcMain.removeListener(replyChannel, onResult);
|
||||
webContents.removeListener("destroyed", onDestroyed);
|
||||
resolve(result);
|
||||
};
|
||||
function onResult(evt, result) {
|
||||
if (evt?.sender !== webContents) return;
|
||||
if (!result || result.requestId !== requestId) return;
|
||||
settle({ success: true, ...result });
|
||||
}
|
||||
function onDestroyed() {
|
||||
settle({ success: false, error: "Target window is gone" });
|
||||
}
|
||||
ipcMain.on(replyChannel, onResult);
|
||||
webContents.once("destroyed", onDestroyed);
|
||||
timeoutId = setTimeout(() => {
|
||||
settle({ success: false, error: "Timed out waiting for editor window" });
|
||||
}, timeoutMs);
|
||||
try {
|
||||
webContents.send(sendChannel, message);
|
||||
} catch (err) {
|
||||
settle({ success: false, error: err?.message || "Failed to send to renderer" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function notifySourcesTabsClosed(electronModule, editorIds) {
|
||||
if (!Array.isArray(editorIds) || editorIds.length === 0) return;
|
||||
const bySource = new Map();
|
||||
for (const editorId of editorIds) {
|
||||
if (typeof editorId !== "string" || !editorId) continue;
|
||||
const owner = tabSources.get(editorId);
|
||||
const wc = resolveSourceWebContents(electronModule, editorId);
|
||||
tabSources.delete(editorId);
|
||||
if (!owner?.accepted || !wc) continue;
|
||||
const list = bySource.get(wc) || [];
|
||||
list.push(editorId);
|
||||
bySource.set(wc, list);
|
||||
}
|
||||
for (const [wc, ids] of bySource) {
|
||||
try {
|
||||
wc.send("netcatty:window:editorTabsClosed", { editorIds: ids });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendOpenTab(electronModule, win, snapshot, source, reused) {
|
||||
if (!source || source.isDestroyed?.()) return Promise.resolve({ success: false, error: "Source window is gone" });
|
||||
const owner = { webContentsId: source.id, accepted: false, opening: null };
|
||||
tabSources.set(snapshot.editorId, owner);
|
||||
owner.opening = (async () => {
|
||||
try {
|
||||
// All opens, including reuse during cold start, share the same load.
|
||||
await editorWindowLoaded;
|
||||
if (!isLiveWindow(win)) return { success: false, error: "Editor window is gone" };
|
||||
const result = await invokeWebContents(
|
||||
electronModule,
|
||||
win.webContents,
|
||||
"netcatty:window:editorOpenTab",
|
||||
"netcatty:window:editorOpenTabResult",
|
||||
snapshot,
|
||||
OPEN_TAB_TIMEOUT_MS,
|
||||
);
|
||||
if (!result.success || result.ok !== true || !isLiveWindow(win)) {
|
||||
return { success: false, error: result.error || "Editor did not accept the file" };
|
||||
}
|
||||
// Pending ownership keeps the source alive while opening, but only
|
||||
// accepted tabs may close the source copy during window cleanup.
|
||||
owner.accepted = true;
|
||||
ctx.showAndFocusWindow(win);
|
||||
return { success: true, reused };
|
||||
} catch (error) {
|
||||
return { success: false, error: error?.message || "Failed to open editor tab" };
|
||||
} finally {
|
||||
if (!owner.accepted && tabSources.get(snapshot.editorId) === owner) tabSources.delete(snapshot.editorId);
|
||||
}
|
||||
})();
|
||||
return owner.opening;
|
||||
}
|
||||
|
||||
async function openEditorWindow(electronModule, options, payload) {
|
||||
const snapshot = sanitizeEditorSnapshot(payload);
|
||||
if (!snapshot) return { success: false, error: "Invalid editor payload" };
|
||||
|
||||
const { BrowserWindow, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir, sourceWindow, sourceWebContents } = options;
|
||||
const source = sourceWebContents || sourceWindow?.webContents;
|
||||
|
||||
const existing = getEditorWindow();
|
||||
if (existing) {
|
||||
if (nativeClosePendingWindow === existing) return { success: false, error: "Editor window is closing" };
|
||||
return sendOpenTab(electronModule, existing, snapshot, source, true);
|
||||
}
|
||||
|
||||
const osTheme = electronModule?.nativeTheme?.shouldUseDarkColors ? "dark" : "light";
|
||||
const effectiveTheme = ctx.currentTheme === "dark" || ctx.currentTheme === "light" ? ctx.currentTheme : osTheme;
|
||||
const frontendBackground = ctx.resolveFrontendBackgroundColor(electronDir || ctx.__dirname || __dirname, effectiveTheme);
|
||||
const backgroundColor = frontendBackground || "#1a1a1a";
|
||||
const { x: editorX, y: editorY } = ctx.resolveSettingsWindowBounds(electronModule, {
|
||||
sourceWindow: sourceWindow || ctx.mainWindow,
|
||||
settingsWidth: EDITOR_WIDTH,
|
||||
settingsHeight: EDITOR_HEIGHT,
|
||||
});
|
||||
|
||||
const windowsChrome = windowsFramelessContentChromeOptions();
|
||||
let editorWindowCloseConfirmed = false;
|
||||
const win = new BrowserWindow({
|
||||
title: snapshot.fileName,
|
||||
width: EDITOR_WIDTH,
|
||||
height: EDITOR_HEIGHT,
|
||||
...(editorX !== undefined && editorY !== undefined ? { x: editorX, y: editorY } : {}),
|
||||
minWidth: 640,
|
||||
minHeight: 420,
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
show: false,
|
||||
frame: false,
|
||||
...(isMac ? { trafficLightPosition: { x: 12, y: 12 } } : {}),
|
||||
...windowsChrome,
|
||||
webPreferences: {
|
||||
preload,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
spellcheck: false,
|
||||
backgroundThrottling: false,
|
||||
v8CacheOptions: ctx.V8_CACHE_OPTIONS,
|
||||
},
|
||||
});
|
||||
editorWindow = win;
|
||||
|
||||
const releaseLifecycle = () => {
|
||||
if (editorWindow === win) {
|
||||
editorWindow = null;
|
||||
editorWindowLoaded = null;
|
||||
}
|
||||
const leftoverIds = Array.from(tabSources.keys());
|
||||
notifySourcesTabsClosed(electronModule, leftoverIds);
|
||||
tabSources.clear();
|
||||
ctx.unregisterAppContentWindow(win);
|
||||
ctx.notifyAppContentWindowClosed(win);
|
||||
};
|
||||
|
||||
ctx.registerAppContentWindow(win, { queryDirtyEditors: true });
|
||||
|
||||
try {
|
||||
win.webContents?.setWindowOpenHandler?.(ctx.createExternalOnlyWindowOpenHandler(shell));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
win.on("close", (event) => {
|
||||
if (ctx.isQuitting || editorWindowCloseConfirmed) return;
|
||||
event.preventDefault();
|
||||
if (nativeClosePendingWindow === win) return;
|
||||
nativeClosePendingWindow = win;
|
||||
// OS close (Alt+F4/titlebar) uses the same renderer Save/Discard/Cancel
|
||||
// flow as closing an owner or a tab, rather than a dirty-only veto.
|
||||
closeEditorTabs(electronModule, { editorIds: Array.from(tabSources.keys()) })
|
||||
.then((result) => {
|
||||
if (!result.success || result.cancelled) return;
|
||||
editorWindowCloseConfirmed = true;
|
||||
if (isLiveWindow(win)) win.close();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[EditorWindow] Close confirmation failed", error);
|
||||
})
|
||||
.finally(() => { if (nativeClosePendingWindow === win) nativeClosePendingWindow = null; });
|
||||
});
|
||||
win.on("closed", releaseLifecycle);
|
||||
win.webContents.on("render-process-gone", () => {
|
||||
// Destroying reuses closed cleanup for ownership and pending requests.
|
||||
if (isLiveWindow(win)) win.destroy();
|
||||
});
|
||||
win.on("page-title-updated", (e) => { e.preventDefault(); });
|
||||
|
||||
try {
|
||||
win.setBackgroundColor(backgroundColor);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
ctx.applyWindowOpacityToWindow(win);
|
||||
|
||||
if (isMac) {
|
||||
try {
|
||||
win.setWindowButtonVisibility(true);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
win.setWindowButtonPosition({ x: 12, y: 12 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// loadURL only guarantees document load, not that React installed the
|
||||
// receiver. Subscribe before navigation so a fast renderer cannot race us.
|
||||
const rendererReady = new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
electronModule.ipcMain.removeListener("netcatty:window:editorReady", onReady);
|
||||
win.removeListener("closed", onClosed);
|
||||
};
|
||||
const onReady = (event) => {
|
||||
if (event.sender !== win.webContents) return;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onClosed = () => {
|
||||
cleanup();
|
||||
reject(new Error("Editor window closed before it was ready"));
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Timed out waiting for editor receiver"));
|
||||
}, OPEN_TAB_TIMEOUT_MS);
|
||||
electronModule.ipcMain.on("netcatty:window:editorReady", onReady);
|
||||
win.once("closed", onClosed);
|
||||
});
|
||||
const editorPath = "#/editor-window";
|
||||
const loadRenderer = async () => {
|
||||
try {
|
||||
if (isDev) {
|
||||
try {
|
||||
const baseUrl = ctx.getDevRendererBaseUrl(devServerUrl);
|
||||
await win.loadURL(`${baseUrl}${editorPath}`);
|
||||
} catch (e) {
|
||||
console.warn("[EditorWindow] Dev server not reachable", e);
|
||||
await win.loadURL(`app://netcatty/index.html${editorPath}`);
|
||||
}
|
||||
} else {
|
||||
await win.loadURL(`app://netcatty/index.html${editorPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isLiveWindow(win)) win.destroy();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
editorWindowLoaded = Promise.all([loadRenderer(), rendererReady]).catch((error) => {
|
||||
if (isLiveWindow(win)) win.destroy();
|
||||
throw error;
|
||||
});
|
||||
return sendOpenTab(electronModule, win, snapshot, source, false);
|
||||
}
|
||||
|
||||
function focusEditorTab(electronModule, editorId) {
|
||||
const win = getEditorWindow();
|
||||
if (!win) return { success: false, error: "Editor window is not open" };
|
||||
try {
|
||||
if (typeof editorId === "string" && editorId) {
|
||||
win.webContents.send("netcatty:window:editorActivateTab", { editorId });
|
||||
}
|
||||
ctx.showAndFocusWindow(win);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err?.message || "Failed to focus editor window" };
|
||||
}
|
||||
}
|
||||
|
||||
async function closeEditorTabs(electronModule, payload) {
|
||||
const editorIds = Array.isArray(payload?.editorIds)
|
||||
? payload.editorIds.filter((id) => typeof id === "string" && id)
|
||||
: [];
|
||||
if (editorIds.length === 0) return { success: true, cancelled: false, closedIds: [] };
|
||||
// Closing an owner or a placeholder during cold start must not overtake
|
||||
// its open receipt. Reuse the same readiness/ownership boundary.
|
||||
const opening = editorIds.map((id) => tabSources.get(id)?.opening).filter(Boolean);
|
||||
const opened = await Promise.all(opening);
|
||||
if (opened.some((result) => !result.success)) {
|
||||
return { success: false, cancelled: true, closedIds: [], error: "Editor transfer did not complete" };
|
||||
}
|
||||
try {
|
||||
await editorWindowLoaded;
|
||||
} catch (error) {
|
||||
return { success: false, cancelled: true, closedIds: [], error: error?.message || "Editor is not ready" };
|
||||
}
|
||||
const win = getEditorWindow();
|
||||
if (!win) {
|
||||
notifySourcesTabsClosed(electronModule, editorIds);
|
||||
return { success: true, cancelled: false, closedIds: editorIds };
|
||||
}
|
||||
const force = payload?.force === true;
|
||||
if (!force) ctx.showAndFocusWindow(win);
|
||||
const result = await invokeWebContents(
|
||||
electronModule,
|
||||
win.webContents,
|
||||
"netcatty:window:editorCloseTabs",
|
||||
"netcatty:window:editorCloseTabsResult",
|
||||
{ editorIds, force },
|
||||
force ? CLOSE_TABS_FORCE_TIMEOUT_MS : CLOSE_TABS_PROMPT_TIMEOUT_MS,
|
||||
);
|
||||
if (!result.success) {
|
||||
if (force) {
|
||||
try {
|
||||
win.webContents.send("netcatty:window:editorCloseTabs", {
|
||||
requestId: randomUUID(),
|
||||
editorIds,
|
||||
force: true,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
notifySourcesTabsClosed(electronModule, editorIds);
|
||||
return { success: true, cancelled: false, closedIds: editorIds };
|
||||
}
|
||||
return { success: false, cancelled: true, closedIds: [], error: result.error };
|
||||
}
|
||||
const closedIds = Array.isArray(result.closedIds) ? result.closedIds : [];
|
||||
notifySourcesTabsClosed(electronModule, closedIds);
|
||||
if (result.cancelled === true) {
|
||||
return { success: true, cancelled: true, closedIds };
|
||||
}
|
||||
return { success: true, cancelled: false, closedIds };
|
||||
}
|
||||
|
||||
async function saveEditorTab(electronModule, payload) {
|
||||
const snapshotLike = sanitizeEditorSnapshot({
|
||||
...payload,
|
||||
fileName: payload?.fileName || "file",
|
||||
content: payload?.content,
|
||||
baselineContent: payload?.content,
|
||||
languageId: "plaintext",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
});
|
||||
if (!snapshotLike) return { ok: false, error: "Invalid save payload" };
|
||||
const source = resolveSourceWebContents(electronModule, snapshotLike.editorId);
|
||||
if (!source) return { ok: false, error: "SFTP editor bridge not registered — cannot save (no SFTP view mounted)" };
|
||||
const result = await invokeWebContents(
|
||||
electronModule,
|
||||
source,
|
||||
"netcatty:window:editorSaveRequest",
|
||||
"netcatty:window:editorSaveResult",
|
||||
{
|
||||
editorId: snapshotLike.editorId,
|
||||
sessionId: snapshotLike.sessionId,
|
||||
sftpTabId: snapshotLike.sftpTabId,
|
||||
hostId: snapshotLike.hostId,
|
||||
remotePath: snapshotLike.remotePath,
|
||||
content: typeof payload?.content === "string" ? payload.content : "",
|
||||
},
|
||||
SAVE_TIMEOUT_MS,
|
||||
);
|
||||
if (!result.success) return { ok: false, error: result.error || "Save failed" };
|
||||
if (result.ok === false) return { ok: false, error: result.error || "Save failed" };
|
||||
if (typeof result.liveConnectionId === "string" && result.liveConnectionId) {
|
||||
const win = getEditorWindow();
|
||||
try {
|
||||
win?.webContents.send("netcatty:window:editorRemapSession", {
|
||||
fromSessionId: snapshotLike.sessionId,
|
||||
toSessionId: result.liveConnectionId,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
liveConnectionId: result.liveConnectionId,
|
||||
};
|
||||
}
|
||||
|
||||
async function dockEditorTab(electronModule, payload) {
|
||||
const snapshot = sanitizeEditorSnapshot(payload);
|
||||
if (!snapshot) return { success: false, error: "Invalid dock payload" };
|
||||
const source = resolveSourceWebContents(electronModule, snapshot.editorId);
|
||||
if (!source) return { success: false, error: "Source window is gone" };
|
||||
const result = await invokeWebContents(
|
||||
electronModule,
|
||||
source,
|
||||
"netcatty:window:editorDockRequest",
|
||||
"netcatty:window:editorDockResult",
|
||||
snapshot,
|
||||
DOCK_TIMEOUT_MS,
|
||||
);
|
||||
if (!result.success || result.ok === false) {
|
||||
return { success: false, error: result.error || "Failed to dock editor tab" };
|
||||
}
|
||||
tabSources.delete(snapshot.editorId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function reportEditorDirty(electronModule, payload) {
|
||||
const editorId = typeof payload?.editorId === "string" ? payload.editorId : "";
|
||||
if (!editorId) return;
|
||||
const source = resolveSourceWebContents(electronModule, editorId);
|
||||
if (!source) return;
|
||||
try {
|
||||
source.send("netcatty:window:editorDirtyChanged", {
|
||||
editorId,
|
||||
dirty: payload?.dirty === true,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function reportEditorTabsClosed(electronModule, payload) {
|
||||
const editorIds = Array.isArray(payload?.editorIds)
|
||||
? payload.editorIds.filter((id) => typeof id === "string" && id)
|
||||
: (typeof payload?.editorId === "string" ? [payload.editorId] : []);
|
||||
notifySourcesTabsClosed(electronModule, editorIds);
|
||||
}
|
||||
|
||||
function remapEditorSession(electronModule, payload) {
|
||||
const fromSessionId = typeof payload?.fromSessionId === "string" ? payload.fromSessionId : "";
|
||||
const toSessionId = typeof payload?.toSessionId === "string" ? payload.toSessionId : "";
|
||||
if (!fromSessionId || !toSessionId || fromSessionId === toSessionId) return;
|
||||
const win = getEditorWindow();
|
||||
if (!win) return;
|
||||
try {
|
||||
win.webContents.send("netcatty:window:editorRemapSession", { fromSessionId, toSessionId });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
openEditorWindow,
|
||||
hasEditorTabsForSource,
|
||||
closeEditorTabsForSource,
|
||||
focusEditorTab,
|
||||
closeEditorTabs,
|
||||
saveEditorTab,
|
||||
dockEditorTab,
|
||||
reportEditorDirty,
|
||||
reportEditorTabsClosed,
|
||||
remapEditorSession,
|
||||
getEditorWindow,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createEditorWindowApi };
|
||||
@@ -0,0 +1,263 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createEditorWindowApi } = require("./editorWindow.cjs");
|
||||
|
||||
const snapshot = (id) => ({ editorId: id, sessionId: "ssh", sftpTabId: "sftp", hostId: "host", remotePath: `/${id}`, fileName: id, content: `edited ${id}`, baselineContent: "original" });
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
function harness() {
|
||||
const ipcMain = new EventEmitter();
|
||||
const windows = [];
|
||||
class BrowserWindow extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.destroyed = false;
|
||||
this.sent = [];
|
||||
this.webContents = new EventEmitter();
|
||||
this.webContents.isDestroyed = () => this.destroyed;
|
||||
this.webContents.send = (channel, payload) => this.sent.push({ channel, payload });
|
||||
windows.push(this);
|
||||
}
|
||||
isDestroyed() { return this.destroyed; }
|
||||
loadURL() { return new Promise((resolve, reject) => { this.loaded = resolve; this.failed = reject; }); }
|
||||
setBackgroundColor() {}
|
||||
close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit("close", event); if (!event.prevented) this.destroy(); }
|
||||
destroy() { this.destroyed = true; this.webContents.emit("destroyed"); this.emit("closed"); }
|
||||
}
|
||||
const source = Object.assign(new EventEmitter(), { id: 99, isDestroyed: () => false, sent: [], send(channel, payload) { this.sent.push({ channel, payload }); } });
|
||||
const sources = new Map([[source.id, source]]);
|
||||
const electron = { BrowserWindow, ipcMain, webContents: { fromId: (id) => sources.get(id) } };
|
||||
const context = { currentTheme: "dark", mainWindow: null, isQuitting: false,
|
||||
V8_CACHE_OPTIONS: "code", resolveFrontendBackgroundColor: () => "#000", resolveSettingsWindowBounds: () => ({}),
|
||||
registerAppContentWindow() {}, unregisterAppContentWindow() {}, notifyAppContentWindowClosed() {},
|
||||
createExternalOnlyWindowOpenHandler() {}, applyWindowOpacityToWindow() {}, showAndFocusWindow(win) { win.shown = true; },
|
||||
};
|
||||
const api = createEditorWindowApi(context);
|
||||
const open = (id, owner = source) => { sources.set(owner.id, owner); return api.openEditorWindow(electron, { sourceWebContents: owner }, snapshot(id)); };
|
||||
const ready = (win) => ipcMain.emit("netcatty:window:editorReady", { sender: win.webContents });
|
||||
const accept = (win, request, ok = true) => ipcMain.emit("netcatty:window:editorOpenTabResult", { sender: win.webContents }, { requestId: request.payload.requestId, ok });
|
||||
return { windows, source, ipcMain, open, ready, accept, api, electron, context };
|
||||
}
|
||||
|
||||
test("concurrent cold opens wait for load and the mounted receiver, then for each receipt", async (t) => {
|
||||
const h = harness();
|
||||
t.after(() => h.windows.forEach((win) => { if (!win.destroyed) win.destroy(); }));
|
||||
let completed = 0;
|
||||
const first = h.open("first").then((result) => { completed++; return result; });
|
||||
const second = h.open("second").then((result) => { completed++; return result; });
|
||||
const win = h.windows[0];
|
||||
assert.equal(h.windows.length, 1);
|
||||
assert.equal(win.sent.length, 0);
|
||||
win.loaded();
|
||||
await tick();
|
||||
assert.equal(win.sent.length, 0, "document load alone is not renderer readiness");
|
||||
h.ipcMain.emit("netcatty:window:editorReady", { sender: {} });
|
||||
await tick();
|
||||
assert.equal(win.sent.length, 0, "another window cannot claim readiness");
|
||||
h.ready(win);
|
||||
await tick();
|
||||
assert.equal(win.sent.length, 2);
|
||||
assert.equal(completed, 0);
|
||||
assert.equal(win.shown, undefined);
|
||||
h.accept(win, win.sent[1]);
|
||||
assert.deepEqual(await second, { success: true, reused: true });
|
||||
assert.equal(completed, 1);
|
||||
h.accept(win, win.sent[0]);
|
||||
assert.deepEqual(await first, { success: true, reused: false });
|
||||
assert.deepEqual(win.sent.map((request) => request.payload.content), ["edited first", "edited second"]);
|
||||
assert.equal(h.ipcMain.listenerCount("netcatty:window:editorOpenTabResult"), 0);
|
||||
});
|
||||
|
||||
test("failed cold load leaves every unaccepted source tab intact", async () => {
|
||||
const h = harness();
|
||||
const first = h.open("first");
|
||||
const second = h.open("second");
|
||||
h.windows[0].failed(new Error("load failed"));
|
||||
assert.equal((await first).success, false);
|
||||
assert.equal((await second).success, false);
|
||||
assert.deepEqual(h.source.sent, [], "cleanup must not close tabs still owned by the source");
|
||||
assert.equal(h.ipcMain.listenerCount("netcatty:window:editorReady"), 0);
|
||||
});
|
||||
|
||||
test("receiver rejection and window loss do not report successful delivery", async () => {
|
||||
const h = harness();
|
||||
const opening = h.open("first");
|
||||
const win = h.windows[0];
|
||||
win.loaded(); h.ready(win); await tick();
|
||||
h.accept(win, win.sent[0], false);
|
||||
assert.equal((await opening).success, false);
|
||||
const next = h.open("second");
|
||||
await tick(); win.destroy();
|
||||
assert.equal((await next).success, false);
|
||||
assert.deepEqual(h.source.sent, []);
|
||||
assert.equal(h.ipcMain.listenerCount("netcatty:window:editorOpenTabResult"), 0);
|
||||
});
|
||||
|
||||
|
||||
test("close during cold start waits for the receiver and the open receipt", async () => {
|
||||
const h = harness();
|
||||
const opening = h.open("first");
|
||||
const closing = h.api.closeEditorTabs(h.electron, { editorIds: ["first"] });
|
||||
const win = h.windows[0];
|
||||
assert.equal(h.api.hasEditorTabsForSource(h.source), true, "pending transfers retain source ownership");
|
||||
assert.equal(win.sent.length, 0);
|
||||
win.loaded(); h.ready(win); await tick();
|
||||
assert.deepEqual(win.sent.map((request) => request.channel), ["netcatty:window:editorOpenTab"]);
|
||||
h.accept(win, win.sent[0]); await opening; await tick();
|
||||
const closeRequest = win.sent[1];
|
||||
assert.equal(closeRequest.channel, "netcatty:window:editorCloseTabs");
|
||||
h.ipcMain.emit("netcatty:window:editorCloseTabsResult", { sender: win.webContents }, { requestId: closeRequest.payload.requestId, cancelled: false, closedIds: ["first"] });
|
||||
assert.deepEqual(await closing, { success: true, cancelled: false, closedIds: ["first"] });
|
||||
assert.equal(h.api.hasEditorTabsForSource(h.source), false);
|
||||
assert.deepEqual(h.source.sent.at(-1), { channel: "netcatty:window:editorTabsClosed", payload: { editorIds: ["first"] } });
|
||||
win.destroy();
|
||||
});
|
||||
|
||||
test("cancelling source-window close preserves its save and dock route and does not close other owners", async () => {
|
||||
const h = harness();
|
||||
const other = Object.assign(new EventEmitter(), { id: 100, isDestroyed: () => false, send() {} });
|
||||
const first = h.open("first");
|
||||
const second = h.open("second", other);
|
||||
const win = h.windows[0];
|
||||
win.loaded(); h.ready(win); await tick();
|
||||
for (const request of win.sent) h.accept(win, request);
|
||||
await Promise.all([first, second]);
|
||||
const closing = h.api.closeEditorTabsForSource(h.electron, h.source);
|
||||
await tick();
|
||||
const request = win.sent.at(-1);
|
||||
assert.deepEqual(request.payload.editorIds, ["first"]);
|
||||
h.ipcMain.emit("netcatty:window:editorCloseTabsResult", { sender: win.webContents }, { requestId: request.payload.requestId, cancelled: true, closedIds: [] });
|
||||
assert.equal((await closing).cancelled, true);
|
||||
assert.equal(h.api.hasEditorTabsForSource(h.source), true);
|
||||
assert.equal(h.api.hasEditorTabsForSource(other), true);
|
||||
const saving = h.api.saveEditorTab(h.electron, snapshot("first"));
|
||||
const saveRequest = h.source.sent.at(-1);
|
||||
assert.equal(saveRequest.channel, "netcatty:window:editorSaveRequest");
|
||||
h.ipcMain.emit("netcatty:window:editorSaveResult", { sender: h.source }, { requestId: saveRequest.payload.requestId, ok: true });
|
||||
assert.equal((await saving).ok, true);
|
||||
const docking = h.api.dockEditorTab(h.electron, snapshot("first"));
|
||||
const dockRequest = h.source.sent.at(-1);
|
||||
assert.equal(dockRequest.channel, "netcatty:window:editorDockRequest");
|
||||
h.ipcMain.emit("netcatty:window:editorDockResult", { sender: h.source }, { requestId: dockRequest.payload.requestId, ok: true });
|
||||
assert.equal((await docking).success, true);
|
||||
assert.equal(h.api.hasEditorTabsForSource(h.source), false);
|
||||
assert.equal(h.api.hasEditorTabsForSource(other), true);
|
||||
win.destroy();
|
||||
});
|
||||
|
||||
test("a failed transfer vetoes a simultaneous source close without deleting its retained copy", async () => {
|
||||
const h = harness();
|
||||
const opening = h.open("first");
|
||||
const closing = h.api.closeEditorTabsForSource(h.electron, h.source);
|
||||
h.windows[0].failed(new Error("load failed"));
|
||||
assert.equal((await opening).success, false);
|
||||
assert.equal((await closing).cancelled, true);
|
||||
assert.deepEqual(h.source.sent, []);
|
||||
});
|
||||
|
||||
|
||||
test("native editor close uses renderer confirmation, preserves Cancel, and coalesces repeated close events", async () => {
|
||||
const h = harness();
|
||||
const opening = h.open("first");
|
||||
const win = h.windows[0];
|
||||
win.loaded(); h.ready(win); await tick();
|
||||
h.accept(win, win.sent[0]); await opening;
|
||||
win.close(); win.close(); await tick();
|
||||
assert.equal(win.isDestroyed(), false);
|
||||
const requests = () => win.sent.filter((request) => request.channel === "netcatty:window:editorCloseTabs");
|
||||
assert.equal(requests().length, 1);
|
||||
assert.deepEqual(requests()[0].payload.editorIds, ["first"]);
|
||||
assert.equal(requests()[0].payload.force, false);
|
||||
h.ipcMain.emit("netcatty:window:editorCloseTabsResult", { sender: win.webContents }, { requestId: requests()[0].payload.requestId, cancelled: true, closedIds: [] });
|
||||
await tick();
|
||||
assert.equal(win.isDestroyed(), false, "Cancel keeps the native window and its contents open");
|
||||
assert.equal(h.api.hasEditorTabsForSource(h.source), true);
|
||||
win.close(); await tick();
|
||||
assert.equal(requests().length, 2, "native close can be retried after Cancel");
|
||||
h.ipcMain.emit("netcatty:window:editorCloseTabsResult", { sender: win.webContents }, { requestId: requests()[1].payload.requestId, cancelled: false, closedIds: ["first"] });
|
||||
await tick();
|
||||
assert.equal(win.isDestroyed(), true, "Save/Discard completion permits native destruction");
|
||||
assert.equal(h.api.hasEditorTabsForSource(h.source), false);
|
||||
});
|
||||
|
||||
|
||||
test("editor module parses in strict mode without a with scope", () => {
|
||||
const source = require("node:fs").readFileSync(require.resolve("./editorWindow.cjs"), "utf8");
|
||||
assert.doesNotThrow(() => new Function("require", "module", "exports", '"use strict";\n' + source));
|
||||
});
|
||||
|
||||
test("editor close reads the live quitting context instead of a captured value", async () => {
|
||||
const h = harness();
|
||||
const opening = h.open("first");
|
||||
const win = h.windows[0];
|
||||
win.loaded(); h.ready(win); await tick();
|
||||
h.accept(win, win.sent[0]); await opening;
|
||||
Object.defineProperty(h.context, "isQuitting", { get: () => true });
|
||||
win.close();
|
||||
assert.equal(win.isDestroyed(), true);
|
||||
assert.equal(win.sent.some((request) => request.channel === "netcatty:window:editorCloseTabs"), false);
|
||||
});
|
||||
|
||||
test("renderer loss destroys the dead window, releases ownership, and allows a fresh open", async (t) => {
|
||||
const h = harness();
|
||||
t.after(() => h.windows.forEach((win) => { if (!win.destroyed) win.destroy(); }));
|
||||
const opening = h.open("first");
|
||||
const win = h.windows[0];
|
||||
win.loaded(); h.ready(win); await tick();
|
||||
h.accept(win, win.sent[0]); await opening;
|
||||
const closing = h.api.closeEditorTabs(h.electron, { editorIds: ["first"] });
|
||||
await tick();
|
||||
win.webContents.emit("render-process-gone", {}, { reason: "crashed" });
|
||||
assert.equal(win.isDestroyed(), true);
|
||||
assert.equal(h.api.getEditorWindow(), null);
|
||||
assert.equal(h.api.hasEditorTabsForSource(h.source), false);
|
||||
assert.equal((await closing).cancelled, true, "pending requests settle without the prompt timeout");
|
||||
assert.deepEqual(h.source.sent.at(-1), { channel: "netcatty:window:editorTabsClosed", payload: { editorIds: ["first"] } });
|
||||
const nextOpening = h.open("second");
|
||||
const replacement = h.windows[1];
|
||||
assert.ok(replacement);
|
||||
replacement.loaded(); h.ready(replacement); await tick();
|
||||
h.accept(replacement, replacement.sent[0]);
|
||||
assert.equal((await nextOpening).success, true);
|
||||
});
|
||||
|
||||
test("renderer loss during a pending handoff leaves its source contents owned by the source", async (t) => {
|
||||
const h = harness();
|
||||
t.after(() => h.windows.forEach((win) => { if (!win.destroyed) win.destroy(); }));
|
||||
const opening = h.open("first");
|
||||
const win = h.windows[0];
|
||||
win.webContents.emit("render-process-gone", {}, { reason: "oom" });
|
||||
assert.equal(win.isDestroyed(), true);
|
||||
assert.equal((await opening).success, false);
|
||||
assert.deepEqual(h.source.sent, [], "unaccepted source copies must not receive a tab-closed notification");
|
||||
});
|
||||
|
||||
|
||||
test("native close rejects new transfers before ownership registration and Cancel restores acceptance", async (t) => {
|
||||
const h = harness();
|
||||
t.after(() => h.windows.forEach((win) => { if (!win.destroyed) win.destroy(); }));
|
||||
const opening = h.open("first");
|
||||
const win = h.windows[0];
|
||||
win.loaded(); h.ready(win); await tick();
|
||||
h.accept(win, win.sent[0]); await opening;
|
||||
win.close(); await tick();
|
||||
const closeRequest = win.sent.at(-1);
|
||||
assert.equal(closeRequest.channel, "netcatty:window:editorCloseTabs");
|
||||
const other = Object.assign(new EventEmitter(), { id: 100, isDestroyed: () => false, sent: [], send(channel, payload) { this.sent.push({ channel, payload }); } });
|
||||
let incomingResult;
|
||||
const incoming = h.open("incoming", other).then((result) => { incomingResult = result; return result; });
|
||||
await tick();
|
||||
// Assert before awaiting the result so the old, erroneously pending open fails promptly.
|
||||
assert.deepEqual(incomingResult, { success: false, error: "Editor window is closing" });
|
||||
await incoming;
|
||||
assert.equal(h.api.hasEditorTabsForSource(other), false);
|
||||
assert.equal(win.sent.some((request) => request.channel === "netcatty:window:editorOpenTab" && request.payload.editorId === "incoming"), false);
|
||||
h.ipcMain.emit("netcatty:window:editorCloseTabsResult", { sender: win.webContents }, { requestId: closeRequest.payload.requestId, cancelled: true, closedIds: [] });
|
||||
await tick();
|
||||
const retry = h.open("incoming", other);
|
||||
await tick();
|
||||
h.accept(win, win.sent.at(-1));
|
||||
assert.equal((await retry).success, true);
|
||||
assert.equal(h.api.hasEditorTabsForSource(other), true);
|
||||
assert.equal(win.isDestroyed(), false);
|
||||
});
|
||||
@@ -347,6 +347,7 @@ function createMainWindowApi(ctx) {
|
||||
let saveStateTimer = null;
|
||||
let thisWindowCloseRequested = false;
|
||||
let dirtyEditorCloseConfirmed = false;
|
||||
let editorCloseCheckPending = false;
|
||||
|
||||
const updateNormalBounds = () => {
|
||||
if (!win.isDestroyed() && !win.isMaximized() && !win.isFullScreen()) {
|
||||
@@ -382,12 +383,22 @@ function createMainWindowApi(ctx) {
|
||||
|
||||
const queryDirtyEditorsBeforeClose = (event, { markCloseRequested = false } = {}) => {
|
||||
event.preventDefault();
|
||||
if (editorCloseCheckPending) return;
|
||||
editorCloseCheckPending = true;
|
||||
if (markCloseRequested) {
|
||||
thisWindowCloseRequested = true;
|
||||
}
|
||||
const dirtyEditorQuery = typeof queryDirtyEditors === "function"
|
||||
? queryDirtyEditors(win.webContents, 5000, { ipcMain: electronModule.ipcMain })
|
||||
: false;
|
||||
const dirtyEditorQuery = (async () => {
|
||||
// A detached editor still uses this renderer's SFTP save/dock route.
|
||||
// Run its existing Save/Discard/Cancel flow before destroying the owner.
|
||||
if (typeof closeEditorTabsForSource === "function") {
|
||||
const result = await closeEditorTabsForSource(electronModule, win.webContents);
|
||||
if (!result.success || result.cancelled) return true;
|
||||
}
|
||||
return typeof queryDirtyEditors === "function"
|
||||
? queryDirtyEditors(win.webContents, 5000, { ipcMain: electronModule.ipcMain })
|
||||
: false;
|
||||
})();
|
||||
Promise.resolve(dirtyEditorQuery)
|
||||
.then((hasDirty) => {
|
||||
if (hasDirty) {
|
||||
@@ -402,17 +413,18 @@ function createMainWindowApi(ctx) {
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
dirtyEditorCloseConfirmed = true;
|
||||
try {
|
||||
win.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
// A failed ownership check must keep the source save route alive.
|
||||
thisWindowCloseRequested = false;
|
||||
})
|
||||
.finally(() => { editorCloseCheckPending = false; });
|
||||
};
|
||||
|
||||
// Save state when window is about to close
|
||||
win.on("close", (event) => {
|
||||
// Recheck if another editor was opened while the local dirty query ran.
|
||||
if (typeof hasEditorTabsForSource === "function" && hasEditorTabsForSource(win.webContents)) {
|
||||
dirtyEditorCloseConfirmed = false;
|
||||
}
|
||||
if (!registerAsMainWindow && registerAsAppContentWindow && !isQuitting && !dirtyEditorCloseConfirmed) {
|
||||
queryDirtyEditorsBeforeClose(event, { markCloseRequested: true });
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createMainWindowApi } = require("./mainWindow.cjs");
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
for (const peer of [true, false]) {
|
||||
test(`${peer ? "peer session" : "main"} window keeps its renderer until detached ownership is released`, async () => {
|
||||
let finish;
|
||||
let ownsTabs = true;
|
||||
let ownerChecks = 0;
|
||||
let dirtyChecks = 0;
|
||||
class Window extends EventEmitter {
|
||||
constructor() {
|
||||
super(); this.destroyed = false;
|
||||
this.webContents = Object.assign(new EventEmitter(), { id: 71, isDestroyed: () => this.destroyed, setWindowOpenHandler() {}, openDevTools() {}, send() {} });
|
||||
}
|
||||
close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit("close", event); if (!event.prevented) { this.destroyed = true; this.emit("closed"); } }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
isMaximized() { return false; }
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { width: 1000, height: 700 }; }
|
||||
setBackgroundColor() {}
|
||||
loadURL() { return Promise.resolve(); }
|
||||
}
|
||||
const api = createMainWindowApi({
|
||||
mainWindow: null, electronApp: null, currentTheme: "light", isQuitting: false,
|
||||
pendingWindowStateWrite: null, queuedWindowState: null,
|
||||
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
|
||||
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
|
||||
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname, URL, require, console, setTimeout, clearTimeout,
|
||||
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }),
|
||||
getMainWindowCount: () => 1,
|
||||
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return null; },
|
||||
getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; },
|
||||
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
|
||||
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {}, shouldCloseWindowFromInput() { return false; },
|
||||
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
|
||||
notifyAppContentWindowClosed() {}, applyWindowOpacityToWindow() {}, closeSettingsWindow() {}, hideSettingsWindow() {},
|
||||
hasEditorTabsForSource: () => ownsTabs,
|
||||
closeEditorTabsForSource: () => { ownerChecks++; return new Promise((resolve) => { finish = resolve; }); },
|
||||
queryDirtyEditors: async () => { dirtyChecks++; return false; },
|
||||
});
|
||||
const win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain: {} }, {
|
||||
preload: "/tmp/preload.cjs", devServerUrl: "http://localhost:5173", isDev: true, isMac: true,
|
||||
electronDir: __dirname, route: peer ? "session-window" : undefined, registerAsMainWindow: !peer,
|
||||
});
|
||||
win.close(); win.close();
|
||||
assert.equal(ownerChecks, 1, "repeat close must share the ownership check");
|
||||
assert.equal(dirtyChecks, 0, "the short dirty query must not race a detached Save/Cancel prompt");
|
||||
assert.equal(win.destroyed, false);
|
||||
finish({ success: true, cancelled: true }); await tick();
|
||||
assert.equal(win.destroyed, false, "Cancel keeps the source save/dock renderer alive");
|
||||
assert.equal(dirtyChecks, 0);
|
||||
win.close();
|
||||
ownsTabs = false;
|
||||
finish({ success: true, cancelled: false }); await tick();
|
||||
assert.equal(dirtyChecks, 1);
|
||||
assert.equal(win.destroyed, true, "close proceeds only after all owned editors settled");
|
||||
});
|
||||
}
|
||||
@@ -60,6 +60,7 @@ test("main/settings/tray call sites wire Windows chrome helpers", () => {
|
||||
const main = readFileSync(path.join(here, "mainWindow.cjs"), "utf8");
|
||||
const settings = readFileSync(path.join(here, "settingsWindow.cjs"), "utf8");
|
||||
const popup = readFileSync(path.join(here, "terminalPopupWindow.cjs"), "utf8");
|
||||
const editor = readFileSync(path.join(here, "editorWindow.cjs"), "utf8");
|
||||
const tray = readFileSync(path.join(here, "../globalShortcutBridge.cjs"), "utf8");
|
||||
const css = readFileSync(path.join(here, "../../../index.css"), "utf8");
|
||||
const html = readFileSync(path.join(here, "../../../index.html"), "utf8");
|
||||
@@ -69,6 +70,7 @@ test("main/settings/tray call sites wire Windows chrome helpers", () => {
|
||||
["mainWindow", main],
|
||||
["settingsWindow", settings],
|
||||
["terminalPopupWindow", popup],
|
||||
["editorWindow", editor],
|
||||
]) {
|
||||
assert.match(source, /require\("\.\/windowsWindowChrome\.cjs"\)/, `${label} must require chrome helpers`);
|
||||
assert.match(source, /windowsFramelessContentChromeOptions/, `${label} must use content chrome helper`);
|
||||
@@ -76,7 +78,7 @@ test("main/settings/tray call sites wire Windows chrome helpers", () => {
|
||||
const requireIndex = source.indexOf('require("./windowsWindowChrome.cjs")');
|
||||
const withIndex = source.indexOf("with (ctx)");
|
||||
assert.ok(
|
||||
requireIndex !== -1 && withIndex !== -1 && requireIndex < withIndex,
|
||||
requireIndex !== -1 && (withIndex === -1 || requireIndex < withIndex),
|
||||
`${label}: require chrome helpers before with(ctx) so injected require cannot remount the path`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -788,6 +788,81 @@ function createBridgeRegistrar(context) {
|
||||
return { success: false, error: err?.message || "Failed to open terminal popup" };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:window:openEditor", async (event, payload) => {
|
||||
try {
|
||||
const sourceWindow = BrowserWindow.fromWebContents(event.sender);
|
||||
return await getWindowManager().openEditorWindow(electronModule, {
|
||||
preload,
|
||||
devServerUrl: effectiveDevServerUrl,
|
||||
isDev,
|
||||
appIcon: getAppIconPath(),
|
||||
isMac,
|
||||
electronDir,
|
||||
sourceWindow,
|
||||
sourceWebContents: event.sender,
|
||||
}, payload);
|
||||
} catch (err) {
|
||||
console.error("[Main] Failed to open editor window:", err);
|
||||
return { success: false, error: err?.message || "Failed to open editor window" };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:window:focusEditor", async (_event, payload) => {
|
||||
try {
|
||||
return getWindowManager().focusEditorTab(electronModule, payload?.editorId);
|
||||
} catch (err) {
|
||||
return { success: false, error: err?.message || "Failed to focus editor window" };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:window:closeEditorTabs", async (_event, payload) => {
|
||||
try {
|
||||
return await getWindowManager().closeEditorWindowTabs(electronModule, payload);
|
||||
} catch (err) {
|
||||
return { success: false, cancelled: true, closedIds: [], error: err?.message || "Failed to close editor tabs" };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:editorWindow:save", async (_event, payload) => {
|
||||
try {
|
||||
return await getWindowManager().saveEditorWindowTab(electronModule, payload);
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || "Save failed" };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:editorWindow:dock", async (_event, payload) => {
|
||||
try {
|
||||
return await getWindowManager().dockEditorTab(electronModule, payload);
|
||||
} catch (err) {
|
||||
return { success: false, error: err?.message || "Failed to dock editor tab" };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("netcatty:editorWindow:dirty", (_event, payload) => {
|
||||
try {
|
||||
getWindowManager().reportEditorDirty(electronModule, payload);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("netcatty:editorWindow:tabsClosed", (_event, payload) => {
|
||||
try {
|
||||
getWindowManager().reportEditorTabsClosed(electronModule, payload);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("netcatty:editorWindow:remapSession", (_event, payload) => {
|
||||
try {
|
||||
getWindowManager().remapEditorSession(electronModule, payload);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
// Cloud sync master password (stored in-memory + persisted via safeStorage)
|
||||
ipcMain.handle("netcatty:cloudSync:session:setPassword", async (_event, password) => {
|
||||
|
||||
@@ -63,6 +63,10 @@ const terminalPopupConfigState = {
|
||||
lastPayload: null,
|
||||
listeners: new Set(),
|
||||
};
|
||||
const editorOpenTabState = {
|
||||
pending: [],
|
||||
listeners: new Set(),
|
||||
};
|
||||
|
||||
function dispatchGlobalSftpTransferEvent(payload) {
|
||||
for (const cb of globalSftpTransferListeners) {
|
||||
@@ -411,6 +415,19 @@ ipcRenderer.on("netcatty:window:terminalPopupConfig", (_event, payload) => {
|
||||
}
|
||||
});
|
||||
});
|
||||
ipcRenderer.on("netcatty:window:editorOpenTab", (_event, payload) => {
|
||||
if (editorOpenTabState.listeners.size === 0) {
|
||||
editorOpenTabState.pending.push(payload);
|
||||
return;
|
||||
}
|
||||
editorOpenTabState.listeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Editor window open-tab callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
ipcRenderer.on("netcatty:zmodem:progress", (_event, payload) => {
|
||||
if (closedTerminalDataSessions.has(payload.sessionId)) return;
|
||||
const set = zmodemListeners.get(payload.sessionId);
|
||||
@@ -879,6 +896,7 @@ const api = createPreloadApi({
|
||||
updateErrorListeners,
|
||||
updateNeedsSaveListeners,
|
||||
terminalPopupConfigState,
|
||||
editorOpenTabState,
|
||||
portForwardStatusListeners,
|
||||
portForwardRuntimeListeners,
|
||||
fileWatchSyncedListeners,
|
||||
|
||||
@@ -1116,6 +1116,79 @@ function createPreloadApi(ctx) {
|
||||
),
|
||||
setWindowTitle: (title) => ipcRenderer.invoke("netcatty:window:setTitle", title),
|
||||
openSessionInNewWindow: (payload) => ipcRenderer.invoke("netcatty:window:openSession", payload),
|
||||
openEditorWindow: (payload) => ipcRenderer.invoke("netcatty:window:openEditor", payload),
|
||||
focusEditorWindow: (editorId) => ipcRenderer.invoke("netcatty:window:focusEditor", { editorId }),
|
||||
closeEditorWindowTabs: (payload) => ipcRenderer.invoke("netcatty:window:closeEditorTabs", payload),
|
||||
saveEditorWindowTab: (payload) => ipcRenderer.invoke("netcatty:editorWindow:save", payload),
|
||||
dockEditorWindowTab: (payload) => ipcRenderer.invoke("netcatty:editorWindow:dock", payload),
|
||||
reportEditorWindowDirty: (payload) => ipcRenderer.send("netcatty:editorWindow:dirty", payload),
|
||||
reportEditorWindowTabsClosed: (payload) => ipcRenderer.send("netcatty:editorWindow:tabsClosed", payload),
|
||||
remapEditorWindowSession: (payload) => ipcRenderer.send("netcatty:editorWindow:remapSession", payload),
|
||||
onEditorWindowOpenTab: (cb) => {
|
||||
const editorOpenTabState = ctx.editorOpenTabState || { pending: [], listeners: new Set() };
|
||||
const receive = (payload) => {
|
||||
try {
|
||||
cb(payload);
|
||||
ipcRenderer.send("netcatty:window:editorOpenTabResult", { requestId: payload.requestId, ok: true });
|
||||
} catch (err) {
|
||||
ipcRenderer.send("netcatty:window:editorOpenTabResult", {
|
||||
requestId: payload.requestId,
|
||||
ok: false,
|
||||
error: err?.message || "Failed to open editor tab",
|
||||
});
|
||||
}
|
||||
};
|
||||
editorOpenTabState.listeners.add(receive);
|
||||
// Drain only to the currently mounted receiver. Deferring this would let
|
||||
// StrictMode cleanup leave a stale callback holding the transfer.
|
||||
for (const payload of editorOpenTabState.pending.splice(0)) receive(payload);
|
||||
ipcRenderer.send("netcatty:window:editorReady");
|
||||
return () => editorOpenTabState.listeners.delete(receive);
|
||||
},
|
||||
onEditorWindowActivateTab: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:window:editorActivateTab", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:window:editorActivateTab", handler);
|
||||
},
|
||||
onEditorWindowCloseTabs: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:window:editorCloseTabs", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:window:editorCloseTabs", handler);
|
||||
},
|
||||
reportEditorWindowCloseTabsResult: (payload) => {
|
||||
ipcRenderer.send("netcatty:window:editorCloseTabsResult", payload);
|
||||
},
|
||||
onEditorWindowRemapSession: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:window:editorRemapSession", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:window:editorRemapSession", handler);
|
||||
},
|
||||
onEditorWindowSaveRequest: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:window:editorSaveRequest", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:window:editorSaveRequest", handler);
|
||||
},
|
||||
reportEditorWindowSaveResult: (payload) => {
|
||||
ipcRenderer.send("netcatty:window:editorSaveResult", payload);
|
||||
},
|
||||
onEditorWindowDockRequest: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:window:editorDockRequest", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:window:editorDockRequest", handler);
|
||||
},
|
||||
reportEditorWindowDockResult: (payload) => {
|
||||
ipcRenderer.send("netcatty:window:editorDockResult", payload);
|
||||
},
|
||||
onEditorWindowDirtyChanged: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:window:editorDirtyChanged", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:window:editorDirtyChanged", handler);
|
||||
},
|
||||
onEditorWindowTabsClosed: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:window:editorTabsClosed", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:window:editorTabsClosed", handler);
|
||||
},
|
||||
onOpenSessionInNewWindow: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:window:openSession", handler);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { createPreloadApi } = require("./api.cjs");
|
||||
|
||||
function harness(pending = []) {
|
||||
const sent = [];
|
||||
const editorOpenTabState = { pending, listeners: new Set() };
|
||||
const api = createPreloadApi({ editorOpenTabState, ipcRenderer: { send: (...args) => sent.push(args) }, webUtils: {} });
|
||||
return { api, sent, editorOpenTabState };
|
||||
}
|
||||
|
||||
test("editor receiver announces readiness after subscribing and acknowledges installed contents", () => {
|
||||
const h = harness();
|
||||
const received = [];
|
||||
const off = h.api.onEditorWindowOpenTab((payload) => {
|
||||
assert.equal(h.sent.length, 1, "receipt must follow installation");
|
||||
received.push(payload.content);
|
||||
});
|
||||
assert.deepEqual(h.sent, [["netcatty:window:editorReady"]]);
|
||||
const message = { requestId: "open-1", content: "unsaved" };
|
||||
for (const receive of h.editorOpenTabState.listeners) receive(message);
|
||||
assert.deepEqual(received, ["unsaved"]);
|
||||
assert.deepEqual(h.sent[1], ["netcatty:window:editorOpenTabResult", { requestId: "open-1", ok: true }]);
|
||||
off();
|
||||
assert.equal(h.editorOpenTabState.listeners.size, 0);
|
||||
});
|
||||
|
||||
test("failed renderer installation sends a negative receipt", () => {
|
||||
const h = harness();
|
||||
h.api.onEditorWindowOpenTab(() => { throw new Error("cannot install"); });
|
||||
for (const receive of h.editorOpenTabState.listeners) receive({ requestId: "open-2" });
|
||||
assert.deepEqual(h.sent.at(-1), ["netcatty:window:editorOpenTabResult", { requestId: "open-2", ok: false, error: "cannot install" }]);
|
||||
});
|
||||
|
||||
test("queued tabs are installed by the mounted receiver before cleanup", () => {
|
||||
const h = harness([{ requestId: "queued", content: "unsaved" }]);
|
||||
let installed = 0;
|
||||
const off = h.api.onEditorWindowOpenTab(() => { installed++; });
|
||||
off();
|
||||
h.api.onEditorWindowOpenTab(() => { installed++; });
|
||||
assert.equal(installed, 1);
|
||||
assert.equal(h.editorOpenTabState.pending.length, 0);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ const WINDOW_SOURCES_WITH_SPELLCHECK = [
|
||||
'bridges/windowManager/mainWindow.cjs',
|
||||
'bridges/windowManager/settingsWindow.cjs',
|
||||
'bridges/windowManager/terminalPopupWindow.cjs',
|
||||
'bridges/windowManager/editorWindow.cjs',
|
||||
'bridges/globalShortcutBridge.cjs',
|
||||
'bridges/windowManager/externalWindows.cjs',
|
||||
'plugins/contributionIconRasterizer.cjs',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AppLockGate } from './components/AppLockGate';
|
||||
const LazySettingsPage = lazy(() => import('./components/SettingsPage'));
|
||||
const LazyTrayPanel = lazy(() => import('./components/TrayPanel'));
|
||||
const LazyTerminalPopupPage = lazy(() => import('./components/TerminalPopupPage'));
|
||||
const LazyEditorWindowPage = lazy(() => import('./components/editor/EditorWindowPage'));
|
||||
|
||||
function SettingsWindowFallback() {
|
||||
return (
|
||||
@@ -144,6 +145,9 @@ const getRoute = () => {
|
||||
if (hash === '#/terminal-popup' || hash.startsWith('#/terminal-popup')) {
|
||||
return 'terminal-popup';
|
||||
}
|
||||
if (hash === '#/editor-window' || hash.startsWith('#/editor-window')) {
|
||||
return 'editor-window';
|
||||
}
|
||||
if (hash === '#/session-window' || hash.startsWith('#/session-window')) {
|
||||
return 'main';
|
||||
}
|
||||
@@ -165,12 +169,14 @@ const syncTrayWindowClass = (route: string) => {
|
||||
const renderApp = () => {
|
||||
const route = getRoute();
|
||||
const isPeerSessionWindow = window.location.hash.startsWith('#/session-window');
|
||||
const isEditorWindow = route === 'editor-window';
|
||||
// Peer session windows must not drive the main window's settings IPC sync
|
||||
// and must not re-apply OS-level system settings effects (tray, global
|
||||
// shortcuts, …) — they follow the main window through the chrome stores.
|
||||
// Editors consume their own settings instance, so keep IPC/storage sync on.
|
||||
const settingsOptions = isPeerSessionWindow
|
||||
? { enableSettingsSync: false, enableSystemEffects: false }
|
||||
: undefined;
|
||||
: isEditorWindow ? { enableSystemEffects: false } : undefined;
|
||||
|
||||
syncTrayWindowClass(route);
|
||||
if (route === 'settings') {
|
||||
@@ -222,6 +228,25 @@ const renderApp = () => {
|
||||
</AppLockGate>
|
||||
</StrictMode>
|
||||
);
|
||||
} else if (route === 'editor-window') {
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<AppLockGate
|
||||
notifyRendererReady={false}
|
||||
forceRenderChildren
|
||||
settingsOptions={settingsOptions}
|
||||
>
|
||||
{({ settings, appLock }) => (
|
||||
<Suspense fallback={<TerminalPopupWindowFallback />}>
|
||||
<LazyEditorWindowPage
|
||||
settings={settings}
|
||||
allowEdit={appLock.initialized && !appLock.locked}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</AppLockGate>
|
||||
</StrictMode>
|
||||
);
|
||||
} else {
|
||||
root.render(
|
||||
<StrictMode>
|
||||
|
||||
+31
@@ -58,6 +58,37 @@ declare global {
|
||||
sourceSession: import("../../domain/models").TerminalSession;
|
||||
localShellType?: import("../../domain/models").TerminalSession['shellType'];
|
||||
}): Promise<{ success: boolean; error?: string }>;
|
||||
openEditorWindow?(payload: import("../../application/state/editorWindowTypes").EditorWindowTabSnapshot): Promise<{ success: boolean; reused?: boolean; error?: string }>;
|
||||
focusEditorWindow?(editorId: string): Promise<{ success: boolean; error?: string }>;
|
||||
closeEditorWindowTabs?(payload: { editorIds: string[]; force?: boolean }): Promise<{
|
||||
success: boolean;
|
||||
cancelled?: boolean;
|
||||
closedIds?: string[];
|
||||
error?: string;
|
||||
}>;
|
||||
saveEditorWindowTab?(payload: {
|
||||
editorId: string;
|
||||
sessionId: string;
|
||||
sftpTabId: string;
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
content: string;
|
||||
}): Promise<{ ok: boolean; liveConnectionId?: string; error?: string }>;
|
||||
dockEditorWindowTab?(payload: import("../../application/state/editorWindowTypes").EditorWindowTabSnapshot): Promise<{ success: boolean; error?: string }>;
|
||||
reportEditorWindowDirty?(payload: { editorId: string; dirty: boolean }): void;
|
||||
reportEditorWindowTabsClosed?(payload: { editorIds?: string[]; editorId?: string }): void;
|
||||
remapEditorWindowSession?(payload: { fromSessionId: string; toSessionId: string }): void;
|
||||
onEditorWindowOpenTab?(cb: (payload: import("../../application/state/editorWindowTypes").EditorWindowTabSnapshot) => void): () => void;
|
||||
onEditorWindowActivateTab?(cb: (payload: { editorId: string }) => void): () => void;
|
||||
onEditorWindowCloseTabs?(cb: (payload: import("../../application/state/editorWindowTypes").EditorWindowCloseTabsRequest) => void): () => void;
|
||||
reportEditorWindowCloseTabsResult?(payload: import("../../application/state/editorWindowTypes").EditorWindowCloseTabsResult): void;
|
||||
onEditorWindowRemapSession?(cb: (payload: { fromSessionId: string; toSessionId: string }) => void): () => void;
|
||||
onEditorWindowSaveRequest?(cb: (payload: import("../../application/state/editorWindowTypes").EditorWindowSaveRequest) => void): () => void;
|
||||
reportEditorWindowSaveResult?(payload: import("../../application/state/editorWindowTypes").EditorWindowSaveResult): void;
|
||||
onEditorWindowDockRequest?(cb: (payload: import("../../application/state/editorWindowTypes").EditorWindowTabSnapshot & { requestId: string }) => void): () => void;
|
||||
reportEditorWindowDockResult?(payload: { requestId: string; ok: boolean; error?: string }): void;
|
||||
onEditorWindowDirtyChanged?(cb: (payload: { editorId: string; dirty: boolean }) => void): () => void;
|
||||
onEditorWindowTabsClosed?(cb: (payload: { editorIds: string[] }) => void): () => void;
|
||||
onOpenSessionInNewWindow?(cb: (payload: {
|
||||
title: string;
|
||||
sourceSession: import("../../domain/models").TerminalSession;
|
||||
|
||||
Reference in new issue
Block a user