Compare commits

...
Author SHA1 Message Date
netcatty-bot 9ecfc2b350 fix: address Codex review on PR #3606 2026-10-08 10:28:17 +00:00
陈大猫 cc8a2f1b76 fix(editor): freeze docking content and gate transfers during close 2026-10-08 17:54:25 +08:00
陈大猫 726904c9b7 fix(editor): use cross-renderer IDs and clear dead window ownership
Use UUID editor IDs and reuse closed cleanup after renderer loss. Revert the unrelated development URL normalization change to the existing mainline behavior.
2026-10-08 17:23:49 +08:00
陈大猫 70dd036bca fix(editor): scope Monaco shortcuts and retain live settings sync
Use Monaco's editorId context to keep Save, Close, Find and Paste on the focused document. Keep editor settings IPC/storage sync enabled while disabling system effects. Replace only the new editor window module's with scope with live ctx property reads.
2026-10-08 16:52:28 +08:00
陈大猫 3ea88abbd9 fix(editor): dismiss clean duplicate modal after successful detached focus 2026-10-08 16:33:58 +08:00
陈大猫 4f7a68d58e fix(editor): route native window close through existing unsaved confirmation 2026-10-08 16:09:37 +08:00
陈大猫 014ecd9ac8 fix(editor): guard app quit while source still owns pending contents 2026-10-08 15:33:42 +08:00
陈大猫 e1f0eb6b43 fix(editor): retain source ownership through detached editor close coordination 2026-10-08 15:14:39 +08:00
陈大猫 f0239bc444 fix(editor): acknowledge detached tab delivery before releasing source contents 2026-10-08 14:41:44 +08:00
pplulee 86d2622a08 Merge branch 'main' of https://github.com/binaricat/Netcatty into feature/editor-detach-window 2026-09-26 11:57:38 +08:00
pplulee 848086739d fix: preserve detached editor changes during saves and modal conflicts 2026-09-26 11:56:03 +08:00
ppluleeandCursor ff52f91f4c Stabilize OTP SFTP reuse test host keys.
Use node:crypto RSA PEM like the sibling SFTP live fixture so ssh2 Server does not reject a malformed ed25519 keygen output.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 16:25:44 +08:00
ppluleeandCursor 4837acf0d5 Keep AppView from subscribing to active tab identity.
Move the detached-editor focus redirect into AppActiveTabChrome and count the editor window as a StrictMode root.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 15:47:59 +08:00
ppluleeandCursor c57431ede7 feat: add a dedicated multi-tab editor window
Keep terminals usable while editing by moving files into a singleton OS window; saves still go through the existing SFTP write path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 15:32:37 +08:00
54 changed files with 2935 additions and 85 deletions

No files matched your search

+11
View File
@@ -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,
+3
View File
@@ -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) => {
+34 -3
View File
@@ -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");
});
+1
View File
@@ -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) {
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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': '不儲存',
+109
View File
@@ -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);
});
+14
View File
@@ -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());
+107 -5
View File
@@ -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);
});
+283
View File
@@ -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();
};
}
+45
View File
@@ -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();
+74
View File
@@ -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,
};
}
+4 -1
View File
@@ -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}
/>
+2
View File
@@ -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>
+18
View File
@@ -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>
+8
View File
@@ -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");
});
+350
View File
@@ -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>
);
}
+84
View File
@@ -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\(/);
});
+57 -6
View File
@@ -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
+3
View File
@@ -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>
);
+3
View File
@@ -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>
+50 -1
View File
@@ -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,
};
+2 -1
View File
@@ -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,
+35 -2
View File
@@ -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());
}),
}));
});
}
+51
View File
@@ -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);
});
+22 -10
View File
@@ -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`,
);
}
+75
View File
@@ -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) => {
+18
View File
@@ -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,
+73
View File
@@ -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);
});
+1
View File
@@ -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',
+26 -1
View File
@@ -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
View File
@@ -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;