mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01fec4e125 | ||
|
|
dc9c7917da | ||
|
|
e22cc82b67 | ||
|
|
7f3aae4c1c | ||
|
|
58bea83ca7 | ||
|
|
396fe1bac7 | ||
|
|
1569f82d66 | ||
|
|
686e41275d | ||
|
|
2950ae7d0a | ||
|
|
0a52749ca3 |
No files matched your search
@@ -602,6 +602,9 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
'terminal.layer.closePanel': 'Close panel',
|
||||
'terminal.layer.closePane': 'Close split',
|
||||
'terminal.layer.saveLayoutAsDefault': 'Save layout as default',
|
||||
'terminal.layer.layoutSavedAsDefault': 'Layout saved as default',
|
||||
'terminal.layer.layoutSaveFailed': 'Could not save layout',
|
||||
'terminal.layer.resizeSplit': 'Resize split',
|
||||
'terminal.layer.splitHorizontal': 'Split top and bottom',
|
||||
'terminal.layer.splitVertical': 'Split left and right',
|
||||
|
||||
@@ -602,6 +602,9 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
'terminal.layer.closePanel': '关闭面板',
|
||||
'terminal.layer.closePane': '关闭分屏',
|
||||
'terminal.layer.saveLayoutAsDefault': '将布局保存为默认',
|
||||
'terminal.layer.layoutSavedAsDefault': '布局已保存为默认',
|
||||
'terminal.layer.layoutSaveFailed': '布局保存失败',
|
||||
'terminal.layer.resizeSplit': '调整分屏大小',
|
||||
'terminal.layer.splitHorizontal': '上下分屏',
|
||||
'terminal.layer.splitVertical': '左右分屏',
|
||||
|
||||
@@ -592,6 +592,9 @@ export const zhTWAiMessages: Messages = {
|
||||
'terminal.layer.movePanelRight': '面板移至右側',
|
||||
'terminal.layer.closePanel': '關閉面板',
|
||||
'terminal.layer.closePane': '關閉分割',
|
||||
'terminal.layer.saveLayoutAsDefault': '將佈局儲存為預設',
|
||||
'terminal.layer.layoutSavedAsDefault': '佈局已儲存為預設',
|
||||
'terminal.layer.layoutSaveFailed': '佈局儲存失敗',
|
||||
'terminal.layer.resizeSplit': '調整分割大小',
|
||||
'terminal.layer.splitHorizontal': '上下分割',
|
||||
'terminal.layer.splitVertical': '左右分割',
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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 {
|
||||
collectSidePanelPanes,
|
||||
createSidePanelLayout,
|
||||
splitSidePanelPane,
|
||||
type SidePanelLayout,
|
||||
} from '../../domain/sidePanelLayout.ts';
|
||||
import { STORAGE_KEY_WORKSPACE_LAYOUT_PRESET } from '../../infrastructure/config/storageKeys.ts';
|
||||
import { useWorkspaceLayoutPresetState } from './useWorkspaceLayoutPresetState.ts';
|
||||
|
||||
function buildSplitLayout(): SidePanelLayout {
|
||||
let layout = createSidePanelLayout('scripts', 'pane-scripts');
|
||||
layout = splitSidePanelPane(layout, 'pane-scripts', 'sftp', 'horizontal', {
|
||||
paneId: 'pane-sftp',
|
||||
splitId: 'split-root',
|
||||
}, 400);
|
||||
return layout;
|
||||
}
|
||||
|
||||
test('saved default resolves per session with fresh ids and protocol-aware pruning', async () => {
|
||||
const values = new Map<string, string>();
|
||||
const fakeStorage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { values.set(key, value); },
|
||||
removeItem: (key: string) => { values.delete(key); },
|
||||
};
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
localStorage?: typeof fakeStorage;
|
||||
};
|
||||
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: fakeStorage,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
let state: ReturnType<typeof useWorkspaceLayoutPresetState> | null = null;
|
||||
const Probe = () => {
|
||||
state = useWorkspaceLayoutPresetState();
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
create(React.createElement(Probe));
|
||||
});
|
||||
assert.ok(state);
|
||||
|
||||
// Nothing saved yet: no preset to apply.
|
||||
assert.equal(state.resolveDefaultLayoutForSession({ protocol: 'ssh' }), null);
|
||||
|
||||
let saved = false;
|
||||
await act(async () => {
|
||||
saved = state?.saveWorkspaceLayoutAsDefault(buildSplitLayout()) ?? false;
|
||||
});
|
||||
assert.equal(saved, true);
|
||||
assert.ok(values.has(STORAGE_KEY_WORKSPACE_LAYOUT_PRESET));
|
||||
|
||||
// SSH sessions keep their SFTP pane; ids are rekeyed per application.
|
||||
const sshResolved = state.resolveDefaultLayoutForSession({ protocol: 'ssh' });
|
||||
assert.ok(sshResolved);
|
||||
assert.equal(sshResolved.protocol, 'ssh');
|
||||
assert.deepEqual(
|
||||
collectSidePanelPanes(sshResolved.layout.root).map((pane) => pane.tool),
|
||||
['scripts', 'sftp'],
|
||||
);
|
||||
assert.notEqual(sshResolved.layout.root.id, buildSplitLayout().root.id);
|
||||
assert.equal(sshResolved.focusedTool, 'sftp');
|
||||
|
||||
// Protocols that cannot serve SFTP lose that pane.
|
||||
const telnetResolved = state.resolveDefaultLayoutForSession({ protocol: 'telnet' });
|
||||
assert.ok(telnetResolved);
|
||||
assert.deepEqual(
|
||||
collectSidePanelPanes(telnetResolved.layout.root).map((pane) => pane.tool),
|
||||
['scripts'],
|
||||
);
|
||||
assert.equal(telnetResolved.focusedTool, 'scripts');
|
||||
|
||||
// A second resolve rekeys again so parallel workspaces share no node ids.
|
||||
const again = state.resolveDefaultLayoutForSession({ protocol: 'ssh' });
|
||||
assert.ok(again);
|
||||
assert.notEqual(again.layout.root.id, sshResolved.layout.root.id);
|
||||
} finally {
|
||||
if (previousLocalStorage) {
|
||||
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
|
||||
} else {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
test('saving falls back to false when persistence rejects the write', async () => {
|
||||
const fakeStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
const quotaError = new DOMException('full', 'QuotaExceededError');
|
||||
throw quotaError;
|
||||
},
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
localStorage?: typeof fakeStorage;
|
||||
};
|
||||
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: fakeStorage,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
let state: ReturnType<typeof useWorkspaceLayoutPresetState> | null = null;
|
||||
const Probe = () => {
|
||||
state = useWorkspaceLayoutPresetState();
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
create(React.createElement(Probe));
|
||||
});
|
||||
assert.ok(state);
|
||||
|
||||
let saved = true;
|
||||
await act(async () => {
|
||||
saved = state?.saveWorkspaceLayoutAsDefault(buildSplitLayout()) ?? true;
|
||||
});
|
||||
assert.equal(saved, false);
|
||||
} finally {
|
||||
if (previousLocalStorage) {
|
||||
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
|
||||
} else {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
getFocusedSidePanelPane,
|
||||
type SidePanelLayout,
|
||||
type SidePanelTool,
|
||||
} from '../../domain/sidePanelLayout';
|
||||
import {
|
||||
createWorkspaceLayoutPreset,
|
||||
rekeySidePanelLayout,
|
||||
withoutUnavailableSidePanelTools,
|
||||
} from '../../domain/workspaceLayoutPreset';
|
||||
import {
|
||||
readWorkspaceLayoutPreset,
|
||||
writeWorkspaceLayoutPreset,
|
||||
} from './workspaceLayoutPresetStore';
|
||||
|
||||
/** Protocols whose sessions can actually serve an SFTP pane. */
|
||||
function canServeSftp(protocol: string): boolean {
|
||||
return protocol === 'local' || protocol === 'ssh' || protocol === 'mosh';
|
||||
}
|
||||
|
||||
export type ResolvedWorkspaceLayoutPreset = {
|
||||
layout: SidePanelLayout;
|
||||
focusedTool: SidePanelTool;
|
||||
/** Connection protocol used for availability filtering (`session.protocol ?? 'ssh'`). */
|
||||
protocol: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Application boundary for the saved default workspace side panel layout.
|
||||
* Owns the preset persistence lifecycle (read, validate, write) so UI
|
||||
* components consume handlers instead of touching the store directly.
|
||||
*/
|
||||
export function useWorkspaceLayoutPresetState() {
|
||||
/**
|
||||
* Resolve the saved default preset into a fresh layout for a newly
|
||||
* connected session. Panes the session cannot serve (SFTP on non-file
|
||||
* protocols) are pruned, and ids are rekeyed so several workspaces can
|
||||
* run the same preset in parallel. Returns null when there is no preset
|
||||
* or nothing survives availability filtering.
|
||||
*/
|
||||
const resolveDefaultLayoutForSession = useCallback(
|
||||
(session: { protocol?: string | null }): ResolvedWorkspaceLayoutPreset | null => {
|
||||
const preset = readWorkspaceLayoutPreset();
|
||||
if (!preset) return null;
|
||||
|
||||
const proto = session.protocol ?? 'ssh';
|
||||
const usable = withoutUnavailableSidePanelTools(
|
||||
preset.layout,
|
||||
(tool) => tool !== 'sftp' || canServeSftp(proto),
|
||||
);
|
||||
if (!usable) return null;
|
||||
|
||||
const layout = rekeySidePanelLayout(usable, () => crypto.randomUUID());
|
||||
return { layout, focusedTool: getFocusedSidePanelPane(layout).tool, protocol: proto };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** Validate and persist a live layout as the default. False when invalid or the write failed. */
|
||||
const saveWorkspaceLayoutAsDefault = useCallback((layout: SidePanelLayout): boolean => {
|
||||
const preset = createWorkspaceLayoutPreset(layout);
|
||||
if (!preset) return false;
|
||||
return writeWorkspaceLayoutPreset(preset);
|
||||
}, []);
|
||||
|
||||
return { resolveDefaultLayoutForSession, saveWorkspaceLayoutAsDefault };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { STORAGE_KEY_WORKSPACE_LAYOUT_PRESET } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
sanitizeWorkspaceLayoutPreset,
|
||||
type WorkspaceLayoutPreset,
|
||||
} from '../../domain/workspaceLayoutPreset';
|
||||
|
||||
/**
|
||||
* Saved default workspace side panel layout. Device-local only: it is not
|
||||
* part of the sync payload because pane arrangements are client-side chrome.
|
||||
*/
|
||||
export function readWorkspaceLayoutPreset(): WorkspaceLayoutPreset | null {
|
||||
return sanitizeWorkspaceLayoutPreset(localStorageAdapter.read(STORAGE_KEY_WORKSPACE_LAYOUT_PRESET));
|
||||
}
|
||||
|
||||
export function writeWorkspaceLayoutPreset(preset: WorkspaceLayoutPreset): boolean {
|
||||
return localStorageAdapter.write(STORAGE_KEY_WORKSPACE_LAYOUT_PRESET, preset);
|
||||
}
|
||||
+127
-15
@@ -119,8 +119,10 @@ import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
sidePanelLayoutHasTool,
|
||||
type SidePanelLayout,
|
||||
type SidePanelSplitDirection,
|
||||
} from '../domain/sidePanelLayout';
|
||||
import { useWorkspaceLayoutPresetState } from '../application/state/useWorkspaceLayoutPresetState';
|
||||
import {
|
||||
isPaneMagnificationSelectionValid,
|
||||
resolvePaneMagnificationCandidate,
|
||||
@@ -455,6 +457,94 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sftpFollowTerminalCwdRef = useRef(sftpFollowTerminalCwd);
|
||||
sftpFollowTerminalCwdRef.current = sftpFollowTerminalCwd;
|
||||
|
||||
// Destructure the stable hook callbacks: the hook returns a fresh object
|
||||
// each render, and depending on that object would recreate the preset
|
||||
// callbacks (and in turn handleStatusChange) on unrelated state updates,
|
||||
// defeating the terminal pane memoization.
|
||||
const { resolveDefaultLayoutForSession, saveWorkspaceLayoutAsDefault: persistWorkspaceLayoutAsDefault } =
|
||||
useWorkspaceLayoutPresetState();
|
||||
|
||||
const applyWorkspaceLayoutPresetForSession = useCallback((session: TerminalSession, tabId: string) => {
|
||||
// Preset persistence and resolution live in the application layer; the
|
||||
// component only mounts the resolved panes into its UI state.
|
||||
const resolved = resolveDefaultLayoutForSession(session);
|
||||
if (!resolved) return false;
|
||||
const { layout, focusedTool, protocol: proto } = resolved;
|
||||
|
||||
for (const pane of collectSidePanelPanes(layout.root)) {
|
||||
if (pane.tool === 'ai') {
|
||||
setAiMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'scripts') {
|
||||
setScriptsMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'theme') {
|
||||
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'system') {
|
||||
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'notes') {
|
||||
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'sftp') {
|
||||
// Mirror the normal SFTP open path: a previously retained panel (kept
|
||||
// mounted while transfers or external edits finished after close) must
|
||||
// not keep suppressing command-triggered refreshes once the preset
|
||||
// reopens SFTP for this tab. Mark the tab as opening first so an
|
||||
// activity callback firing before React commits the open state still
|
||||
// sees the panel as open instead of pruning the retained host state.
|
||||
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
|
||||
if (cleanupTimer !== undefined) {
|
||||
window.clearTimeout(cleanupTimer);
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpOpeningTabIdsRef.current.add(tabId);
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
const host = hostsRef.current.find(h => h.id === session.hostId);
|
||||
const hostWithOverrides: Host = host
|
||||
? {
|
||||
...host,
|
||||
protocol: session.protocol ?? host.protocol,
|
||||
port: session.port ?? host.port,
|
||||
moshEnabled: session.moshEnabled ?? host.moshEnabled,
|
||||
etEnabled: session.etEnabled ?? host.etEnabled,
|
||||
}
|
||||
: {
|
||||
id: session.hostId || session.id,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
port: session.port ?? 22,
|
||||
protocol: proto,
|
||||
label: session.customName || session.hostLabel || session.hostname,
|
||||
} as Host;
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, hostWithOverrides);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setSidePanelLayouts((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, layout);
|
||||
return next;
|
||||
});
|
||||
lastSidePanelTabRef.current.set(tabId, focusedTool);
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, focusedTool);
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
}, [resolveDefaultLayoutForSession, setSidePanelLayouts, setSidePanelOpenTabs]);
|
||||
|
||||
const handleSaveWorkspaceLayoutAsDefault = useCallback((layout: SidePanelLayout): boolean => {
|
||||
if (!persistWorkspaceLayoutAsDefault(layout)) {
|
||||
toast.error(t('terminal.layer.layoutSaveFailed'));
|
||||
return false;
|
||||
}
|
||||
toast.success(t('terminal.layer.layoutSavedAsDefault'));
|
||||
return true;
|
||||
}, [persistWorkspaceLayoutAsDefault, t]);
|
||||
|
||||
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
onUpdateSessionStatus(sessionId, status);
|
||||
|
||||
@@ -462,41 +552,62 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const session = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!session) return;
|
||||
const proto = session.protocol ?? 'ssh';
|
||||
const tabId = session.workspaceId || sessionId;
|
||||
const workspaceId = session.workspaceId;
|
||||
const tabId = workspaceId || sessionId;
|
||||
|
||||
// Within a workspace every member session maps to the same tab id, so the
|
||||
// preset (and any auto-open host binding) must come from the workspace's
|
||||
// focused session; otherwise whichever pane connects first would decide
|
||||
// SFTP pruning and host selection regardless of focus.
|
||||
let presetSession = session;
|
||||
if (workspaceId) {
|
||||
const focusedSessionId = workspacesRef.current.find(ws => ws.id === workspaceId)?.focusedSessionId;
|
||||
const focusedSession = focusedSessionId
|
||||
? sessionsRef.current.find(s => s.id === focusedSessionId)
|
||||
: undefined;
|
||||
if (focusedSession) presetSession = focusedSession;
|
||||
}
|
||||
const proto = presetSession.protocol ?? 'ssh';
|
||||
|
||||
if (sidePanelOpenTabsRef.current.has(tabId)) return;
|
||||
|
||||
const targetPanel = resolveSessionSidePanelAutoOpen({
|
||||
session,
|
||||
session: presetSession,
|
||||
terminalEnabled: terminalSidePanelAutoOpenRef.current,
|
||||
terminalTab: terminalSidePanelAutoOpenTabRef.current,
|
||||
localEnabled: localShellSidePanelAutoOpenRef.current,
|
||||
localTab: localShellSidePanelAutoOpenTabRef.current,
|
||||
legacySftpEnabled: sftpAutoOpenSidebarRef.current,
|
||||
});
|
||||
if (!targetPanel) return;
|
||||
|
||||
// Explicit SFTP (JumpServer deep link / host.autoOpenSftpPanel) wins over a
|
||||
// saved default that may omit file transfer — only when SFTP is actually
|
||||
// available. Ordinary auto-open still yields to the saved default.
|
||||
if (targetPanel !== "sftp") {
|
||||
if (applyWorkspaceLayoutPresetForSession(presetSession, tabId)) return;
|
||||
if (!targetPanel) return;
|
||||
}
|
||||
|
||||
lastSidePanelTabRef.current.set(tabId, targetPanel);
|
||||
|
||||
if (targetPanel === 'sftp') {
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
const host = hostsRef.current.find(h => h.id === session.hostId);
|
||||
const host = hostsRef.current.find(h => h.id === presetSession.hostId);
|
||||
const hostWithOverrides: Host = host
|
||||
? {
|
||||
...host,
|
||||
protocol: session.protocol ?? host.protocol,
|
||||
port: session.port ?? host.port,
|
||||
moshEnabled: session.moshEnabled ?? host.moshEnabled,
|
||||
etEnabled: session.etEnabled ?? host.etEnabled,
|
||||
protocol: presetSession.protocol ?? host.protocol,
|
||||
port: presetSession.port ?? host.port,
|
||||
moshEnabled: presetSession.moshEnabled ?? host.moshEnabled,
|
||||
etEnabled: presetSession.etEnabled ?? host.etEnabled,
|
||||
}
|
||||
: {
|
||||
id: session.hostId || sessionId,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
port: session.port ?? 22,
|
||||
id: presetSession.hostId || presetSession.id,
|
||||
hostname: presetSession.hostname,
|
||||
username: presetSession.username,
|
||||
port: presetSession.port ?? 22,
|
||||
protocol: proto,
|
||||
label: session.label || session.hostname,
|
||||
label: presetSession.customName || presetSession.hostLabel || presetSession.hostname,
|
||||
} as Host;
|
||||
|
||||
setSftpHostForTab(prev => {
|
||||
@@ -521,7 +632,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.set(tabId, targetPanel);
|
||||
return next;
|
||||
});
|
||||
}, [onUpdateSessionStatus, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
|
||||
}, [applyWorkspaceLayoutPresetForSession, onUpdateSessionStatus, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
|
||||
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
|
||||
const intent = resolveTerminalSessionExitIntent(
|
||||
@@ -2423,6 +2534,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpHostForTab,
|
||||
setSftpInitialLocationForTab,
|
||||
onSaveWorkspaceLayoutAsDefault: handleSaveWorkspaceLayoutAsDefault,
|
||||
setSftpPendingUploadsForTab,
|
||||
showHostTreeSidebar,
|
||||
sidePanelOpenTabs,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Activity, FolderTree, History, Maximize2, MessageSquare, Minimize2, NotebookText, Palette, PanelLeft, PanelRight, Play, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
|
||||
import { Activity, FolderTree, History, Maximize2, MessageSquare, Minimize2, NotebookText, Palette, PanelLeft, PanelRight, Play, Save, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
|
||||
import {
|
||||
buildSidePanelChromeThemeFromTerminalTheme,
|
||||
buildTerminalSidePanelCssVars,
|
||||
@@ -1203,6 +1203,28 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
t={t}
|
||||
buttonColor={sidePanelTheme.mutedFg}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{ color: sidePanelTheme.mutedFg }}
|
||||
disabled={!activeSidePanelLayout || !ctx.onSaveWorkspaceLayoutAsDefault}
|
||||
aria-label={t('terminal.layer.saveLayoutAsDefault')}
|
||||
onClick={() => {
|
||||
if (activeSidePanelLayout) {
|
||||
ctx.onSaveWorkspaceLayoutAsDefault?.(activeSidePanelLayout);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Save size={15} />
|
||||
</Btn>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t('terminal.layer.saveLayoutAsDefault')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Btn
|
||||
|
||||
@@ -566,6 +566,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
onOpenVaultHostFromChat: s.onOpenVaultHostFromChat,
|
||||
onOpenVaultSectionFromChat: s.onOpenVaultSectionFromChat,
|
||||
onOpenVaultSnippetFromChat: s.onOpenVaultSnippetFromChat,
|
||||
onSaveWorkspaceLayoutAsDefault: s.onSaveWorkspaceLayoutAsDefault,
|
||||
onStartSessionDrag: s.onStartSessionDrag,
|
||||
onEndSessionDrag: s.onEndSessionDrag,
|
||||
onSplitSession: s.onSplitSession,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createWorkspaceLayoutPreset,
|
||||
rekeySidePanelLayout,
|
||||
sanitizeWorkspaceLayoutPreset,
|
||||
withoutUnavailableSidePanelTools,
|
||||
} from './workspaceLayoutPreset.ts';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
createSidePanelLayout,
|
||||
getFocusedSidePanelPane,
|
||||
splitSidePanelPane,
|
||||
} from './sidePanelLayout.ts';
|
||||
|
||||
function buildSplitLayout() {
|
||||
let layout = createSidePanelLayout('scripts', 'pane-scripts');
|
||||
layout = splitSidePanelPane(layout, 'pane-scripts', 'sftp', 'horizontal', {
|
||||
paneId: 'pane-sftp',
|
||||
splitId: 'split-root',
|
||||
}, 400);
|
||||
layout = splitSidePanelPane(layout, 'pane-scripts', 'notes', 'vertical', {
|
||||
paneId: 'pane-notes',
|
||||
splitId: 'split-scripts',
|
||||
}, 200);
|
||||
return layout;
|
||||
}
|
||||
|
||||
test('sanitize accepts a valid saved preset and rejects malformed data', () => {
|
||||
const preset = createWorkspaceLayoutPreset(buildSplitLayout());
|
||||
assert.ok(preset);
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset(preset)?.layout.focusedPaneId, preset.layout.focusedPaneId);
|
||||
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset(null), null);
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset({ version: 2, layout: preset?.layout }), null);
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset({ version: 1 }), null);
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset({ version: 1, layout: { focusedPaneId: 'x' } }), null);
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
version: 1,
|
||||
layout: { root: { id: 'p', type: 'pane', tool: 'nope' }, focusedPaneId: 'p' },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
version: 1,
|
||||
layout: { root: { id: 'p', type: 'pane', tool: 'notes' }, focusedPaneId: 'missing' },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
version: 1,
|
||||
layout: {
|
||||
root: {
|
||||
id: 's',
|
||||
type: 'split',
|
||||
direction: 'diagonal',
|
||||
children: [
|
||||
{ id: 'a', type: 'pane', tool: 'notes' },
|
||||
{ id: 'b', type: 'pane', tool: 'ai' },
|
||||
],
|
||||
sizes: [0.5, 0.5],
|
||||
},
|
||||
focusedPaneId: 'a',
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
version: 1,
|
||||
layout: {
|
||||
root: {
|
||||
id: 's',
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ id: 'a', type: 'pane', tool: 'notes' },
|
||||
{ id: 'b', type: 'pane', tool: 'ai' },
|
||||
],
|
||||
sizes: [0.5, 0, 0.5],
|
||||
},
|
||||
focusedPaneId: 'a',
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('sanitize rejects duplicate node ids', () => {
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
version: 1,
|
||||
layout: {
|
||||
root: {
|
||||
id: 's',
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ id: 'dup', type: 'pane', tool: 'notes' },
|
||||
{ id: 'dup', type: 'pane', tool: 'ai' },
|
||||
],
|
||||
sizes: [0.5, 0.5],
|
||||
},
|
||||
focusedPaneId: 'dup',
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('sanitize rejects duplicate pane tools', () => {
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
version: 1,
|
||||
layout: {
|
||||
root: {
|
||||
id: 's',
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ id: 'a', type: 'pane', tool: 'notes' },
|
||||
{ id: 'b', type: 'pane', tool: 'notes' },
|
||||
],
|
||||
sizes: [0.5, 0.5],
|
||||
},
|
||||
focusedPaneId: 'a',
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('rekey keeps structure, sizes and focused ordinal with fresh ids', () => {
|
||||
const source = buildSplitLayout();
|
||||
const sourceFocusedTool = getFocusedSidePanelPane(source).tool;
|
||||
let counter = 0;
|
||||
const rekeyed = rekeySidePanelLayout(source, () => `fresh-${++counter}`);
|
||||
|
||||
assert.deepEqual(collectSidePanelPanes(rekeyed.root).map((pane) => pane.tool),
|
||||
collectSidePanelPanes(source.root).map((pane) => pane.tool));
|
||||
assert.equal(getFocusedSidePanelPane(rekeyed).tool, sourceFocusedTool);
|
||||
// Fresh ids are assigned depth-first; focus follows the focused pane's
|
||||
// ordinal position in the source layout.
|
||||
const sourcePanes = collectSidePanelPanes(source.root);
|
||||
const rekeyedPanes = collectSidePanelPanes(rekeyed.root);
|
||||
const focusedIndex = sourcePanes.findIndex((pane) => pane.id === 'pane-notes');
|
||||
assert.equal(rekeyed.focusedPaneId, rekeyedPanes[focusedIndex].id);
|
||||
assert.equal(getFocusedSidePanelPane(rekeyed).id, rekeyedPanes[focusedIndex].id);
|
||||
assert.ok(!rekeyedPanes.some((pane) => pane.id === 'pane-notes'));
|
||||
|
||||
const sourceSplits = (source.root as { children: unknown[] }).children;
|
||||
const rekeyedSplits = (rekeyed.root as { children: unknown[] }).children;
|
||||
assert.notDeepEqual(rekeyedSplits, sourceSplits);
|
||||
assert.notEqual(rekeyed.root.id, source.root.id);
|
||||
});
|
||||
|
||||
test('withoutUnavailableSidePanelTools prunes only unavailable panes', () => {
|
||||
const layout = buildSplitLayout();
|
||||
const kept = withoutUnavailableSidePanelTools(layout, (tool) => tool !== 'sftp');
|
||||
assert.ok(kept);
|
||||
assert.deepEqual(collectSidePanelPanes(kept.root).map((pane) => pane.tool), ['scripts', 'notes']);
|
||||
assert.ok(getFocusedSidePanelPane(kept));
|
||||
|
||||
// The remaining split sizes renormalize after pruning (close collapse).
|
||||
const split = kept.root as { type: 'split'; sizes: number[] };
|
||||
assert.equal(split.sizes.reduce((sum, size) => sum + size, 0), 1);
|
||||
|
||||
// Removing the only remaining pane collapses the layout entirely.
|
||||
assert.equal(withoutUnavailableSidePanelTools(createSidePanelLayout('notes', 'p'), () => false), null);
|
||||
// Nothing unavailable keeps the layout untouched.
|
||||
assert.equal(withoutUnavailableSidePanelTools(layout, () => true), layout);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
MAX_SIDE_PANEL_PANES,
|
||||
closeSidePanelPane,
|
||||
collectSidePanelPanes,
|
||||
getFocusedSidePanelPane,
|
||||
type SidePanelLayout,
|
||||
type SidePanelLayoutNode,
|
||||
type SidePanelTool,
|
||||
} from './sidePanelLayout';
|
||||
|
||||
export const WORKSPACE_LAYOUT_PRESET_VERSION = 1 as const;
|
||||
|
||||
/**
|
||||
* Device-local snapshot of a side panel arrangement (split panes + tools)
|
||||
* the user promoted to their default. New terminal sessions apply it on
|
||||
* connect so the preferred workspace needs no manual re-setup.
|
||||
*/
|
||||
export type WorkspaceLayoutPreset = {
|
||||
version: typeof WORKSPACE_LAYOUT_PRESET_VERSION;
|
||||
layout: SidePanelLayout;
|
||||
};
|
||||
|
||||
const SIDE_PANEL_TOOLS: ReadonlySet<SidePanelTool> = new Set([
|
||||
'sftp',
|
||||
'scripts',
|
||||
'history',
|
||||
'theme',
|
||||
'ai',
|
||||
'system',
|
||||
'notes',
|
||||
]);
|
||||
|
||||
function isSidePanelTool(value: unknown): value is SidePanelTool {
|
||||
return typeof value === 'string' && SIDE_PANEL_TOOLS.has(value as SidePanelTool);
|
||||
}
|
||||
|
||||
function sanitizeSidePanelLayoutNode(
|
||||
raw: unknown,
|
||||
seenIds: Set<string>,
|
||||
seenTools: Set<SidePanelTool>,
|
||||
): SidePanelLayoutNode | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const node = raw as Record<string, unknown>;
|
||||
if (typeof node.id !== 'string' || node.id.length === 0 || seenIds.has(node.id)) return null;
|
||||
seenIds.add(node.id);
|
||||
|
||||
if (node.type === 'pane') {
|
||||
if (!isSidePanelTool(node.tool) || seenTools.has(node.tool)) return null;
|
||||
seenTools.add(node.tool);
|
||||
return { id: node.id, type: 'pane', tool: node.tool };
|
||||
}
|
||||
|
||||
if (node.type !== 'split') return null;
|
||||
if (node.direction !== 'horizontal' && node.direction !== 'vertical') return null;
|
||||
if (!Array.isArray(node.children) || node.children.length < 2) return null;
|
||||
if (!Array.isArray(node.sizes) || node.sizes.length !== node.children.length) return null;
|
||||
|
||||
const children: SidePanelLayoutNode[] = [];
|
||||
const sizes: number[] = [];
|
||||
for (let index = 0; index < node.children.length; index += 1) {
|
||||
const child = sanitizeSidePanelLayoutNode(node.children[index], seenIds, seenTools);
|
||||
if (!child) return null;
|
||||
const size = node.sizes[index];
|
||||
if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0) return null;
|
||||
children.push(child);
|
||||
sizes.push(size);
|
||||
}
|
||||
return { id: node.id, type: 'split', direction: node.direction, children, sizes };
|
||||
}
|
||||
|
||||
/** Validate persisted/unknown data into a usable preset, or null when unusable. */
|
||||
export function sanitizeWorkspaceLayoutPreset(raw: unknown): WorkspaceLayoutPreset | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
if (value.version !== WORKSPACE_LAYOUT_PRESET_VERSION) return null;
|
||||
|
||||
const layoutRaw = value.layout;
|
||||
if (!layoutRaw || typeof layoutRaw !== 'object') return null;
|
||||
const seenIds = new Set<string>();
|
||||
const seenTools = new Set<SidePanelTool>();
|
||||
const root = sanitizeSidePanelLayoutNode(
|
||||
(layoutRaw as Record<string, unknown>).root,
|
||||
seenIds,
|
||||
seenTools,
|
||||
);
|
||||
if (!root) return null;
|
||||
|
||||
const panes = collectSidePanelPanes(root);
|
||||
if (panes.length === 0 || panes.length > MAX_SIDE_PANEL_PANES) return null;
|
||||
const focusedPaneId = (layoutRaw as Record<string, unknown>).focusedPaneId;
|
||||
if (typeof focusedPaneId !== 'string' || !panes.some((pane) => pane.id === focusedPaneId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { version: WORKSPACE_LAYOUT_PRESET_VERSION, layout: { root, focusedPaneId } };
|
||||
}
|
||||
|
||||
/** Build a preset from a live layout, reusing the same validation as reads. */
|
||||
export function createWorkspaceLayoutPreset(
|
||||
layout: SidePanelLayout,
|
||||
): WorkspaceLayoutPreset | null {
|
||||
return sanitizeWorkspaceLayoutPreset({
|
||||
version: WORKSPACE_LAYOUT_PRESET_VERSION,
|
||||
layout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a preset layout with fresh pane/split ids so the same preset can be
|
||||
* applied to many workspaces without sharing node identities. Focus follows
|
||||
* the focused pane's ordinal position in the source layout.
|
||||
*/
|
||||
export function rekeySidePanelLayout(
|
||||
layout: SidePanelLayout,
|
||||
generateId: () => string,
|
||||
): SidePanelLayout {
|
||||
const remap = (node: SidePanelLayoutNode): SidePanelLayoutNode => {
|
||||
if (node.type === 'pane') return { ...node, id: generateId() };
|
||||
return {
|
||||
...node,
|
||||
id: generateId(),
|
||||
children: node.children.map(remap),
|
||||
};
|
||||
};
|
||||
|
||||
const root = remap(layout.root);
|
||||
const sourcePanes = collectSidePanelPanes(layout.root);
|
||||
const sourceFocused = getFocusedSidePanelPane(layout);
|
||||
const focusedIndex = Math.max(
|
||||
0,
|
||||
sourcePanes.findIndex((pane) => pane.id === sourceFocused.id),
|
||||
);
|
||||
const focusedPaneId = collectSidePanelPanes(root)[focusedIndex]?.id ?? layout.focusedPaneId;
|
||||
return { root, focusedPaneId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop panes whose tool cannot work for the target session (pruning splits
|
||||
* like a manual pane close). Returns null when nothing remains.
|
||||
*/
|
||||
export function withoutUnavailableSidePanelTools(
|
||||
layout: SidePanelLayout,
|
||||
isToolAvailable: (tool: SidePanelTool) => boolean,
|
||||
): SidePanelLayout | null {
|
||||
let current = layout;
|
||||
for (const pane of collectSidePanelPanes(layout.root)) {
|
||||
if (isToolAvailable(pane.tool)) continue;
|
||||
const next = closeSidePanelPane(current, pane.id);
|
||||
if (!next) return null;
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -242,6 +242,13 @@ export const STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS = 'netcatty_ssh_transport_idl
|
||||
// Workspace Focus Indicator Style
|
||||
export const STORAGE_KEY_WORKSPACE_FOCUS_STYLE = 'netcatty_workspace_focus_style_v1';
|
||||
|
||||
/**
|
||||
* Default side panel layout (split panes + tools) the user promoted from a
|
||||
* live workspace; applied automatically to freshly connected sessions.
|
||||
* Device-local only, not part of the sync payload.
|
||||
*/
|
||||
export const STORAGE_KEY_WORKSPACE_LAYOUT_PRESET = 'netcatty_workspace_layout_preset_v1';
|
||||
|
||||
// Vault: Show Recently Connected hosts section
|
||||
export const STORAGE_KEY_SHOW_RECENT_HOSTS = 'netcatty_show_recent_hosts_v1';
|
||||
export const STORAGE_KEY_HOST_CLICK_BEHAVIOR = 'netcatty_host_click_behavior_v1';
|
||||
|
||||
Reference in new issue
Block a user