Compare commits

...
Author SHA1 Message Date
bincxz 01fec4e125 fix(terminal): skip saved layout only when SFTP auto-open is available
Telnet/plugin hosts can carry autoOpenSidePanel=sftp even though the
protocol cannot serve SFTP. Those sessions should still get the saved
default with the SFTP pane pruned.
2026-09-21 15:35:11 +08:00
bincxz dc9c7917da fix(terminal): let explicit SFTP auto-open beat the saved layout
A JumpServer SFTP deep link or host.autoOpenSftpPanel must still open
file transfer when the saved default layout omitted SFTP.
2026-09-21 15:20:10 +08:00
netcatty-bot e22cc82b67 fix: address Codex review on PR #3456 2026-09-20 07:16:11 +00:00
netcatty-bot 7f3aae4c1c fix: address Codex review on PR #3456 2026-09-20 06:51:51 +00:00
netcatty-bot 58bea83ca7 fix: address Codex review on PR #3456 2026-09-20 06:02:55 +00:00
netcatty-bot 396fe1bac7 fix: address Codex review on PR #3456 2026-09-20 05:34:12 +00:00
netcatty-bot 1569f82d66 fix: address Codex review on PR #3456 2026-09-20 05:09:12 +00:00
netcatty-bot 686e41275d fix: address Codex review on PR #3456 2026-09-20 04:42:36 +00:00
netcatty-bot 2950ae7d0a fix: address Codex review on PR #3456 2026-09-20 04:09:41 +00:00
netcatty-bot 0a52749ca3 fix(#3455): automated AI fix 2026-09-20 03:45:50 +00:00
12 changed files with 728 additions and 16 deletions

No files matched your search

+3
View File
@@ -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',
+3
View File
@@ -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': '左右分屏',
+3
View File
@@ -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
View File
@@ -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,
+176
View File
@@ -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);
});
+153
View File
@@ -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;
}
+7
View File
@@ -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';