mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
7c55381f39
* Add terminals to workspace + New Workspace from QuickSwitcher
Two entry points share a single multi-select picker that lets the
user add Local Terminal + any combination of hosts into a workspace:
1. Focus-mode sidebar "+" button appends the selected targets to the
active workspace as new panes.
2. QuickSwitcher "New Workspace" button (small inline action next to
the Jump To hint) spins up a brand-new workspace tab populated
with the selected targets.
## Changes
### domain/workspace.ts
- pruneWorkspaceNode now rebalances surviving siblings to EQUAL
sizes after removal, instead of re-normalising the prior skew.
Matches the "auto-redistribute on close" expectation.
- New appendPaneToWorkspaceRoot(root, sessionId, direction='vertical'):
if root already splits in the requested direction, pushes the new
pane onto its children and resets sizes to equal; otherwise wraps
root + new pane in a new 0.5/0.5 split. Flattens long chains of
appends instead of producing degenerate nested trees.
### application/state/useSessionState.ts
- appendHostToWorkspace(workspaceId, host, direction?) — atomic
"build a session for this host and append it to the root", keeps
activeTab on the workspace and focuses the new pane.
- appendLocalTerminalToWorkspace(workspaceId, options?, direction?)
— mirror of the above for local shells.
- createWorkspaceFromTargets(targets, name?) — accepts a mixed list
of {kind:'local',...} / {kind:'host',host} and creates a new
workspace with one pane per target. Defaults viewMode to 'focus'
so the QuickSwitcher flow lands in the sidebar layout.
- All three exported from the hook.
### components/workspace/AddToWorkspaceDialog.tsx (new)
QuickSwitcher-styled multi-select picker:
- Fixed top-center overlay, same chrome as QuickSwitcher (border,
shadow, rounded-xl, borderless search input, bg-primary/15 cursor).
- Two sections: Local Shells (currently just Local Terminal) and
Hosts. Hover follows keyboard cursor.
- Toggle rows with click or Space / Enter; ⌘/Ctrl+Enter submits;
Esc closes. Right-side Check marks visible items.
- Thin footer bar with Cancel + "Add N" button.
### App.tsx
- Root-mounted single instance of AddToWorkspaceDialog with a
discriminated-union state:
{ mode: 'append'; workspaceId } | { mode: 'create' } | null.
- onAdd dispatches based on mode — append loops through the picker
targets calling the two append helpers; create calls
createWorkspaceFromTargets once.
- TerminalLayer's focus "+" now sends an onRequestAddToWorkspace
(workspaceId) up to App instead of owning its own dialog.
- QuickSwitcher's onCreateWorkspace callback repurposed to open the
dialog in create mode (replaces the older CreateWorkspaceDialog
route for this specific flow).
### components/TerminalLayer.tsx
- Dropped the inline AddToWorkspaceDialog + addHostPanelOpen state;
replaced the two append callbacks with a single
onRequestAddToWorkspace prop wired to the "+" button.
- Focus-sidebar header: replaced the "Terminals · N" counter with an
immersive borderless search input (bg-transparent, shadow-none,
termFg color) for filtering the terminal list; "+" and Columns2
buttons moved to the right.
- Session list filtered client-side by the search term across
hostLabel / hostname / username.
### components/QuickSwitcher.tsx
- Re-introduced onCreateWorkspace prop (was removed as unused).
- "New Workspace" inline button (Plus icon + label) sits on the
right of the Jump To hint row: border, rounded, hover bg. Click
fires onCreateWorkspace then closes QS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add configurable New Workspace shortcut
Mirrors QuickSwitcher's "+ New Workspace" button via a keyboard
binding so the dialog can open in one keystroke without passing
through QS.
- domain/models.ts: new DEFAULT_KEY_BINDINGS entry id=new-workspace,
action=newWorkspace, default ⌘+Shift+J (Mac) / Ctrl+Shift+J (PC).
Audited the defaults — only quick-switch uses J (⌘+J), so the
shifted combo is free. The binding sits in the 'app' category so
it shows up in Settings → Shortcuts and can be rebound by the user.
- application/state/useGlobalHotkeys.ts: wire newWorkspace into the
HotkeyActions interface, getAppLevelActions() allowlist, and the
global keydown switch so the scheme-driven handler dispatches it.
- App.tsx: handle case 'newWorkspace' inside executeHotkeyAction by
calling setAddToWorkspaceDialog({ mode: 'create' }) — same entry
as QuickSwitcher's button, just without having to open QS first.
- application/i18n/locales/zh-CN.ts: add '新建工作区' translation for
settings.shortcuts.binding.new-workspace. English falls back to
the KeyBinding.label field ("New Workspace"), so no en.ts change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P1: don't check setState flag after the updater returns
Codex flagged that appendHostToWorkspace / appendLocalTerminalToWorkspace
were racy: both flipped an `inserted` flag inside setWorkspaces'
updater and then read it synchronously to decide whether to commit
the matching session via setSessions. React does NOT guarantee
updaters run synchronously (concurrent rendering, StrictMode
double-invoke, etc.), so the flag could still be false at the read
site even though the workspace exists. In that case setSessions was
skipped while the queued workspace update could still insert a new
pane referencing newSessionId — leaving a pane with no backing
session in state.
Fix: add a workspacesRef kept in sync with the workspaces state on
every render, and perform the existence check synchronously *before*
queuing any setState. Once we've confirmed the workspace exists on
the latest committed state, both setWorkspaces and setSessions are
called unconditionally, so they can never diverge.
The ref approach also correctly handles the multi-target append
loop path — React batches the updaters and applies them in sequence,
so sibling pane/session writes land in matching order.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P1+P2: narrow prune rebalance; append in root direction
### P1 — pruneWorkspaceNode over-rebalanced ancestor splits
The equal-sizes rebalance was unconditional during the recursive
walk, so closing a pane deep in one branch also rewrote unrelated
ancestor ratios (e.g., a root 0.8/0.2 vertical split got normalised
to 0.5/0.5 when a grand-child horizontal pane closed).
Now each split level tracks whether it actually lost a DIRECT
child. Only splits where a direct child disappeared get their
siblings reset to equal sizes. Ancestors whose direct children all
survived keep their original ratios (defensively re-normalised in
case a descendant subtree collapsed shape).
### P2 — Append path ignored the root's current direction
onAdd in App.tsx called the two append helpers without a direction,
so both defaulted to 'vertical'. appendPaneToWorkspaceRoot only
flattens into the root split when the directions match; if the
workspace root was horizontal (e.g., user split top/bottom earlier),
each append wrapped the entire existing tree into one side of a new
vertical split — existing panes crammed into one branch, new pane
hoarding half the space.
Read the current root direction out of the target workspace and
pass it down so new panes become peers of the existing root
siblings regardless of horizontal vs vertical.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P2: allow serial hosts in create-workspace picker
The picker used to filter out every host with protocol='serial'
regardless of mode. That was correct for append mode (the
appendHostToWorkspace helper has no serial path and early-returns)
but a regression for create mode — the old createWorkspaceWithHosts
flow passed serial hosts through and createWorkspaceFromTargets
still builds a SerialConfig-backed session for them, so there was
no reason to block them in the "+ New Workspace" entry.
Move the filter from the dialog up to App.tsx:
- AddToWorkspaceDialog drops the serial filter; selectableHosts is
simply the hosts prop.
- App.tsx passes `hosts.filter(h => h.protocol !== 'serial')` when
mode is 'append', and the full list when mode is 'create'.
Result: users can once again build a workspace from serial hosts
via QuickSwitcher's "+ New Workspace" button or the ⌘/Ctrl+Shift+J
hotkey, while append-to-existing keeps its earlier safe behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P2: don't commit session when append target disappears
Follow-up to the earlier ref-based guard. The ref check eliminates
the common "workspace already gone" case but still leaves a small
race: if closeWorkspace runs between the ref read and setWorkspaces'
updater firing, prev.map returns the unchanged workspaces but
setSessions / setActiveTabId still execute — leaving an orphan
session whose workspaceId points at a deleted workspace and jumping
activeTabId to a closed tab.
Nest setSessions + setActiveTabId inside the setWorkspaces updater
so the writes are gated on the same authoritative match used for
the tree update. The setSessions updater also de-dupes by newSessionId
so React 18 StrictMode's dev-time double-invoke of the outer updater
doesn't append the same row twice. Same pattern applied to
appendLocalTerminalToWorkspace.
The existing closeSession already uses the nested-setState shape, so
this matches the codebase convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1068 lines
37 KiB
TypeScript
1068 lines
37 KiB
TypeScript
import { MouseEvent,useCallback,useMemo,useRef,useState } from 'react';
|
|
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
|
|
import {
|
|
appendPaneToWorkspaceRoot,
|
|
collectSessionIds,
|
|
createWorkspaceFromSessions as createWorkspaceEntity,
|
|
createWorkspaceFromSessionIds,
|
|
FocusDirection,
|
|
getNextFocusSessionId,
|
|
insertPaneIntoWorkspace,
|
|
pruneWorkspaceNode,
|
|
SplitDirection,
|
|
SplitHint,
|
|
updateWorkspaceSplitSizes,
|
|
} from '../../domain/workspace';
|
|
import { activeTabStore } from './activeTabStore';
|
|
|
|
// LogView represents an open log replay tab
|
|
export interface LogView {
|
|
id: string; // Tab ID (log-${connectionLogId})
|
|
connectionLogId: string;
|
|
log: ConnectionLog;
|
|
}
|
|
|
|
export const useSessionState = () => {
|
|
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
|
// Latest workspaces snapshot for synchronous existence checks outside
|
|
// setWorkspaces updaters — React doesn't guarantee updaters run
|
|
// synchronously, so relying on a flag flipped inside them to decide
|
|
// whether to also call setSessions is racy and can leave orphan panes.
|
|
const workspacesRef = useRef(workspaces);
|
|
workspacesRef.current = workspaces;
|
|
// activeTabId is now managed by external store - components subscribe directly
|
|
const setActiveTabId = activeTabStore.setActiveTabId;
|
|
const [draggingSessionId, setDraggingSessionId] = useState<string | null>(null);
|
|
const [sessionRenameTarget, setSessionRenameTarget] = useState<TerminalSession | null>(null);
|
|
const [sessionRenameValue, setSessionRenameValue] = useState('');
|
|
const [workspaceRenameTarget, setWorkspaceRenameTarget] = useState<Workspace | null>(null);
|
|
const [workspaceRenameValue, setWorkspaceRenameValue] = useState('');
|
|
// Tab order: stores ordered list of tab IDs (orphan session IDs and workspace IDs)
|
|
const [tabOrder, setTabOrder] = useState<string[]>([]);
|
|
// Broadcast mode: stores workspace IDs that have broadcast enabled
|
|
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
|
|
// Log views: stores open log replay tabs
|
|
const [logViews, setLogViews] = useState<LogView[]>([]);
|
|
|
|
const createLocalTerminal = useCallback((options?: {
|
|
shellType?: TerminalSession['shellType'];
|
|
shell?: string;
|
|
shellArgs?: string[];
|
|
shellName?: string;
|
|
shellIcon?: string;
|
|
}) => {
|
|
const sessionId = crypto.randomUUID();
|
|
const localHostId = `local-${sessionId}`;
|
|
const newSession: TerminalSession = {
|
|
id: sessionId,
|
|
hostId: localHostId,
|
|
hostLabel: options?.shellName || 'Local Terminal',
|
|
hostname: 'localhost',
|
|
username: 'local',
|
|
status: 'connecting',
|
|
protocol: 'local',
|
|
shellType: options?.shellType,
|
|
localShell: options?.shell,
|
|
localShellArgs: options?.shellArgs,
|
|
localShellName: options?.shellName,
|
|
localShellIcon: options?.shellIcon,
|
|
};
|
|
setSessions(prev => [...prev, newSession]);
|
|
setActiveTabId(sessionId);
|
|
return sessionId;
|
|
}, [setActiveTabId]);
|
|
|
|
const createSerialSession = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
|
const sessionId = crypto.randomUUID();
|
|
const serialHostId = `serial-${sessionId}`;
|
|
const portName = config.path.split('/').pop() || config.path;
|
|
const newSession: TerminalSession = {
|
|
id: sessionId,
|
|
hostId: serialHostId,
|
|
hostLabel: `Serial: ${portName}`,
|
|
hostname: config.path,
|
|
username: '',
|
|
status: 'connecting',
|
|
protocol: 'serial',
|
|
serialConfig: config,
|
|
charset: options?.charset,
|
|
};
|
|
setSessions(prev => [...prev, newSession]);
|
|
setActiveTabId(sessionId);
|
|
return sessionId;
|
|
}, [setActiveTabId]);
|
|
|
|
const connectToHost = useCallback((host: Host) => {
|
|
// Handle serial hosts specially - use createSerialSession for them
|
|
if (host.protocol === 'serial') {
|
|
// Use stored serialConfig or construct from host data
|
|
const serialConfig: SerialConfig = host.serialConfig || {
|
|
path: host.hostname,
|
|
baudRate: host.port || 115200,
|
|
dataBits: 8,
|
|
stopBits: 1,
|
|
parity: 'none',
|
|
flowControl: 'none',
|
|
localEcho: false,
|
|
lineMode: false,
|
|
};
|
|
|
|
const sessionId = crypto.randomUUID();
|
|
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
|
const newSession: TerminalSession = {
|
|
id: sessionId,
|
|
hostId: host.id,
|
|
hostLabel: host.label || `Serial: ${portName}`,
|
|
hostname: serialConfig.path,
|
|
username: '',
|
|
status: 'connecting',
|
|
protocol: 'serial',
|
|
serialConfig: serialConfig,
|
|
charset: host.charset,
|
|
};
|
|
setSessions(prev => [...prev, newSession]);
|
|
setActiveTabId(sessionId);
|
|
return sessionId;
|
|
}
|
|
|
|
const newSession: TerminalSession = {
|
|
id: crypto.randomUUID(),
|
|
hostId: host.id,
|
|
hostLabel: host.label,
|
|
hostname: host.hostname,
|
|
username: host.username,
|
|
status: 'connecting',
|
|
// Store connection-time protocol settings from the host object
|
|
protocol: host.protocol,
|
|
port: host.port,
|
|
moshEnabled: host.moshEnabled,
|
|
charset: host.charset,
|
|
};
|
|
setSessions(prev => [...prev, newSession]);
|
|
setActiveTabId(newSession.id);
|
|
return newSession.id;
|
|
}, [setActiveTabId]);
|
|
|
|
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
|
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
|
|
}, []);
|
|
|
|
const closeWorkspace = useCallback((workspaceId: string) => {
|
|
setWorkspaces(prevWorkspaces => {
|
|
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
|
|
|
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
|
|
|
|
const currentActiveTabId = activeTabStore.getActiveTabId();
|
|
if (currentActiveTabId === workspaceId) {
|
|
if (remainingWorkspaces.length > 0) {
|
|
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
|
|
} else {
|
|
setActiveTabId('vault');
|
|
}
|
|
}
|
|
|
|
return remainingWorkspaces;
|
|
});
|
|
}, [setActiveTabId]);
|
|
|
|
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
|
|
e?.stopPropagation();
|
|
|
|
// Pre-compute outside the setSessions updater so we don't depend on React
|
|
// having run the updater by the time we queue the microtask. React 18+ does
|
|
// not guarantee updater execution timing under concurrent scheduling.
|
|
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
|
|
const workspaceIdToMaybeClose =
|
|
sessionBeingClosed?.workspaceId &&
|
|
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
|
|
? sessionBeingClosed.workspaceId
|
|
: undefined;
|
|
|
|
setSessions(prevSessions => {
|
|
const targetSession = prevSessions.find(s => s.id === sessionId);
|
|
const wsId = targetSession?.workspaceId;
|
|
|
|
setWorkspaces(prevWorkspaces => {
|
|
let removedWorkspaceId: string | null = null;
|
|
let nextWorkspaces = prevWorkspaces;
|
|
let dissolvedWorkspaceId: string | null = null;
|
|
let lastRemainingSessionId: string | null = null;
|
|
|
|
if (wsId) {
|
|
nextWorkspaces = prevWorkspaces
|
|
.map(ws => {
|
|
if (ws.id !== wsId) return ws;
|
|
const pruned = pruneWorkspaceNode(ws.root, sessionId);
|
|
if (!pruned) {
|
|
removedWorkspaceId = ws.id;
|
|
return null;
|
|
}
|
|
|
|
// Check if only 1 session remains - dissolve workspace
|
|
const remainingSessionIds = collectSessionIds(pruned);
|
|
if (remainingSessionIds.length === 1) {
|
|
dissolvedWorkspaceId = ws.id;
|
|
lastRemainingSessionId = remainingSessionIds[0];
|
|
return null;
|
|
}
|
|
|
|
return { ...ws, root: pruned };
|
|
})
|
|
.filter((ws): ws is Workspace => Boolean(ws));
|
|
}
|
|
|
|
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
|
|
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
|
|
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
|
|
|
|
const currentActiveTabId = activeTabStore.getActiveTabId();
|
|
const getFallback = () => {
|
|
if (lastRemainingSessionId) return lastRemainingSessionId;
|
|
if (fallbackWorkspace) return fallbackWorkspace.id;
|
|
if (fallbackSolo) return fallbackSolo.id;
|
|
return 'vault';
|
|
};
|
|
|
|
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
|
|
setActiveTabId(getFallback());
|
|
} else if (currentActiveTabId === sessionId) {
|
|
setActiveTabId(getFallback());
|
|
} else if (removedWorkspaceId && currentActiveTabId === removedWorkspaceId) {
|
|
setActiveTabId(getFallback());
|
|
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
|
|
setActiveTabId(getFallback());
|
|
}
|
|
|
|
return nextWorkspaces;
|
|
});
|
|
|
|
// Check if we need to dissolve a workspace (convert remaining session to orphan)
|
|
if (targetSession?.workspaceId) {
|
|
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
|
|
if (ws) {
|
|
const pruned = pruneWorkspaceNode(ws.root, sessionId);
|
|
if (pruned) {
|
|
const remainingSessionIds = collectSessionIds(pruned);
|
|
if (remainingSessionIds.length === 1) {
|
|
// Dissolve: remove workspaceId from the remaining session
|
|
return prevSessions
|
|
.filter(s => s.id !== sessionId)
|
|
.map(s => remainingSessionIds.includes(s.id) ? { ...s, workspaceId: undefined } : s);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return prevSessions.filter(s => s.id !== sessionId);
|
|
});
|
|
|
|
if (workspaceIdToMaybeClose) {
|
|
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
|
|
}
|
|
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
|
|
|
|
const startSessionRename = useCallback((sessionId: string) => {
|
|
setSessions(prevSessions => {
|
|
const target = prevSessions.find(s => s.id === sessionId);
|
|
if (target) {
|
|
setSessionRenameTarget(target);
|
|
setSessionRenameValue(target.hostLabel);
|
|
}
|
|
return prevSessions;
|
|
});
|
|
}, []);
|
|
|
|
const submitSessionRename = useCallback(() => {
|
|
setSessionRenameValue(prevValue => {
|
|
const name = prevValue.trim();
|
|
if (!name) return prevValue;
|
|
|
|
setSessionRenameTarget(prevTarget => {
|
|
if (!prevTarget) return prevTarget;
|
|
setSessions(prev => prev.map(s => s.id === prevTarget.id ? { ...s, hostLabel: name } : s));
|
|
return null;
|
|
});
|
|
|
|
return '';
|
|
});
|
|
}, []);
|
|
|
|
const resetSessionRename = useCallback(() => {
|
|
setSessionRenameTarget(null);
|
|
setSessionRenameValue('');
|
|
}, []);
|
|
|
|
const startWorkspaceRename = useCallback((workspaceId: string) => {
|
|
setWorkspaces(prevWorkspaces => {
|
|
const target = prevWorkspaces.find(w => w.id === workspaceId);
|
|
if (target) {
|
|
setWorkspaceRenameTarget(target);
|
|
setWorkspaceRenameValue(target.title);
|
|
}
|
|
return prevWorkspaces;
|
|
});
|
|
}, []);
|
|
|
|
const submitWorkspaceRename = useCallback(() => {
|
|
setWorkspaceRenameValue(prevValue => {
|
|
const name = prevValue.trim();
|
|
if (!name) return prevValue;
|
|
|
|
setWorkspaceRenameTarget(prevTarget => {
|
|
if (!prevTarget) return prevTarget;
|
|
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: name } : w));
|
|
return null;
|
|
});
|
|
|
|
return '';
|
|
});
|
|
}, []);
|
|
|
|
const resetWorkspaceRename = useCallback(() => {
|
|
setWorkspaceRenameTarget(null);
|
|
setWorkspaceRenameValue('');
|
|
}, []);
|
|
|
|
const createWorkspaceWithHosts = useCallback((name: string, hosts: Host[]) => {
|
|
if (hosts.length === 0) return;
|
|
|
|
// Create sessions for each host
|
|
const newSessions: TerminalSession[] = hosts.map(host => {
|
|
// Handle serial hosts specially
|
|
if (host.protocol === 'serial') {
|
|
const serialConfig: SerialConfig = host.serialConfig || {
|
|
path: host.hostname,
|
|
baudRate: host.port || 115200,
|
|
dataBits: 8,
|
|
stopBits: 1,
|
|
parity: 'none',
|
|
flowControl: 'none',
|
|
localEcho: false,
|
|
lineMode: false,
|
|
};
|
|
|
|
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
hostId: host.id,
|
|
hostLabel: host.label || `Serial: ${portName}`,
|
|
hostname: serialConfig.path,
|
|
username: '',
|
|
status: 'connecting',
|
|
protocol: 'serial',
|
|
serialConfig: serialConfig,
|
|
charset: host.charset,
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
hostId: host.id,
|
|
hostLabel: host.label,
|
|
hostname: host.hostname,
|
|
username: host.username,
|
|
status: 'connecting',
|
|
protocol: host.protocol,
|
|
port: host.port,
|
|
moshEnabled: host.moshEnabled,
|
|
charset: host.charset,
|
|
};
|
|
});
|
|
|
|
const sessionIds = newSessions.map(s => s.id);
|
|
|
|
// Create workspace
|
|
const workspace = createWorkspaceFromSessionIds(sessionIds, {
|
|
title: name,
|
|
viewMode: 'split',
|
|
});
|
|
|
|
// Assign workspaceId to sessions
|
|
const sessionsWithWorkspace = newSessions.map(s => ({
|
|
...s,
|
|
workspaceId: workspace.id
|
|
}));
|
|
|
|
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
|
|
setWorkspaces(prev => [...prev, workspace]);
|
|
setActiveTabId(workspace.id);
|
|
}, [setActiveTabId]);
|
|
|
|
// Like createWorkspaceWithHosts but supports mixed targets — each
|
|
// entry is either an SSH host or a local terminal. Used by the
|
|
// "New Workspace" flow in QuickSwitcher.
|
|
type WorkspaceTarget =
|
|
| { kind: 'local'; shellType?: TerminalSession['shellType']; shell?: string; shellArgs?: string[]; shellName?: string; shellIcon?: string }
|
|
| { kind: 'host'; host: Host };
|
|
|
|
const createWorkspaceFromTargets = useCallback((targets: WorkspaceTarget[], name: string = 'Workspace'): string | null => {
|
|
if (targets.length === 0) return null;
|
|
|
|
const newSessions: TerminalSession[] = targets.map((target) => {
|
|
if (target.kind === 'local') {
|
|
const sessionId = crypto.randomUUID();
|
|
return {
|
|
id: sessionId,
|
|
hostId: `local-${sessionId}`,
|
|
hostLabel: target.shellName || 'Local Terminal',
|
|
hostname: 'localhost',
|
|
username: 'local',
|
|
status: 'connecting',
|
|
protocol: 'local',
|
|
shellType: target.shellType,
|
|
localShell: target.shell,
|
|
localShellArgs: target.shellArgs,
|
|
localShellName: target.shellName,
|
|
localShellIcon: target.shellIcon,
|
|
};
|
|
}
|
|
const host = target.host;
|
|
if (host.protocol === 'serial') {
|
|
const serialConfig: SerialConfig = host.serialConfig || {
|
|
path: host.hostname,
|
|
baudRate: host.port || 115200,
|
|
dataBits: 8,
|
|
stopBits: 1,
|
|
parity: 'none',
|
|
flowControl: 'none',
|
|
localEcho: false,
|
|
lineMode: false,
|
|
};
|
|
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
hostId: host.id,
|
|
hostLabel: host.label || `Serial: ${portName}`,
|
|
hostname: serialConfig.path,
|
|
username: '',
|
|
status: 'connecting',
|
|
protocol: 'serial',
|
|
serialConfig,
|
|
charset: host.charset,
|
|
};
|
|
}
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
hostId: host.id,
|
|
hostLabel: host.label,
|
|
hostname: host.hostname,
|
|
username: host.username,
|
|
status: 'connecting',
|
|
protocol: host.protocol,
|
|
port: host.port,
|
|
moshEnabled: host.moshEnabled,
|
|
charset: host.charset,
|
|
};
|
|
});
|
|
|
|
const sessionIds = newSessions.map((s) => s.id);
|
|
// Default to focus-mode (sidebar layout) regardless of target
|
|
// count — matches the intent behind the QuickSwitcher "New
|
|
// Workspace" flow, which the user expects to land in focus view.
|
|
const workspace = createWorkspaceFromSessionIds(sessionIds, {
|
|
title: name,
|
|
viewMode: 'focus',
|
|
});
|
|
const sessionsWithWorkspace = newSessions.map((s) => ({ ...s, workspaceId: workspace.id }));
|
|
|
|
setSessions((prev) => [...prev, ...sessionsWithWorkspace]);
|
|
setWorkspaces((prev) => [...prev, workspace]);
|
|
setActiveTabId(workspace.id);
|
|
return workspace.id;
|
|
}, [setActiveTabId]);
|
|
|
|
const createWorkspaceFromSessions = useCallback((
|
|
baseSessionId: string,
|
|
joiningSessionId: string,
|
|
hint: SplitHint
|
|
) => {
|
|
if (!hint || baseSessionId === joiningSessionId) return;
|
|
|
|
setSessions(prevSessions => {
|
|
const base = prevSessions.find(s => s.id === baseSessionId);
|
|
const joining = prevSessions.find(s => s.id === joiningSessionId);
|
|
if (!base || !joining || base.workspaceId || joining.workspaceId) return prevSessions;
|
|
|
|
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
|
|
setWorkspaces(prev => [...prev, newWorkspace]);
|
|
setActiveTabId(newWorkspace.id);
|
|
|
|
return prevSessions.map(s => {
|
|
if (s.id === baseSessionId || s.id === joiningSessionId) {
|
|
return { ...s, workspaceId: newWorkspace.id };
|
|
}
|
|
return s;
|
|
});
|
|
});
|
|
}, [setActiveTabId]);
|
|
|
|
const addSessionToWorkspace = useCallback((
|
|
workspaceId: string,
|
|
sessionId: string,
|
|
hint: SplitHint
|
|
) => {
|
|
if (!hint) return;
|
|
|
|
setSessions(prevSessions => {
|
|
const session = prevSessions.find(s => s.id === sessionId);
|
|
if (!session || session.workspaceId) return prevSessions;
|
|
|
|
setWorkspaces(prevWorkspaces => {
|
|
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
|
|
if (!targetWorkspace) return prevWorkspaces;
|
|
|
|
return prevWorkspaces.map(ws => {
|
|
if (ws.id !== workspaceId) return ws;
|
|
return { ...ws, root: insertPaneIntoWorkspace(ws.root, sessionId, hint) };
|
|
});
|
|
});
|
|
|
|
setActiveTabId(workspaceId);
|
|
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
|
|
});
|
|
}, [setActiveTabId]);
|
|
|
|
// Add a host into an existing workspace by creating a new session for
|
|
// that host and appending it as the last pane at the workspace root.
|
|
// Sibling sizes are rebalanced equally by appendPaneToWorkspaceRoot.
|
|
// Unlike addSessionToWorkspace (which takes a pre-created orphan
|
|
// session and a SplitHint), this is atomic — the new session is born
|
|
// already bound to the target workspace and focused.
|
|
const appendHostToWorkspace = useCallback((
|
|
workspaceId: string,
|
|
host: Host,
|
|
direction: SplitDirection = 'vertical',
|
|
): string | null => {
|
|
// Serial hosts use a different session constructor; they currently
|
|
// only enter workspaces via createSerialSession + drag, so reject
|
|
// them here to avoid a partially-constructed session.
|
|
if (host.protocol === 'serial') return null;
|
|
|
|
// Cheap early-exit using the ref when the workspace is clearly
|
|
// absent. The authoritative check lives inside the setWorkspaces
|
|
// updater below so we also cover the concurrent-close race.
|
|
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
|
|
|
|
const newSessionId = crypto.randomUUID();
|
|
const newSession: TerminalSession = {
|
|
id: newSessionId,
|
|
hostId: host.id,
|
|
hostLabel: host.label,
|
|
hostname: host.hostname,
|
|
username: host.username,
|
|
status: 'connecting',
|
|
protocol: host.protocol,
|
|
port: host.port,
|
|
moshEnabled: host.moshEnabled,
|
|
charset: host.charset,
|
|
workspaceId,
|
|
};
|
|
|
|
// Nest setSessions + setActiveTabId inside the setWorkspaces updater
|
|
// so we only commit the session when the workspace update actually
|
|
// matched — otherwise a concurrent closeWorkspace between the ref
|
|
// check and the updater firing would leave an orphan session with a
|
|
// workspaceId pointing at nothing, and active tab would jump to a
|
|
// closed id. The inner setSessions is idempotent (id dedupe) so
|
|
// StrictMode's dev-time double-invoke does not duplicate the row.
|
|
setWorkspaces(prev => {
|
|
const target = prev.find(w => w.id === workspaceId);
|
|
if (!target) return prev;
|
|
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
|
|
setActiveTabId(workspaceId);
|
|
return prev.map(ws => {
|
|
if (ws.id !== workspaceId) return ws;
|
|
return {
|
|
...ws,
|
|
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
|
|
focusedSessionId: newSessionId,
|
|
};
|
|
});
|
|
});
|
|
return newSessionId;
|
|
}, [setActiveTabId]);
|
|
|
|
// Atomic "append a local terminal pane" — mirror of appendHostToWorkspace
|
|
// but constructs a local-protocol session instead of an SSH one.
|
|
const appendLocalTerminalToWorkspace = useCallback((
|
|
workspaceId: string,
|
|
options?: {
|
|
shellType?: TerminalSession['shellType'];
|
|
shell?: string;
|
|
shellArgs?: string[];
|
|
shellName?: string;
|
|
shellIcon?: string;
|
|
},
|
|
direction: SplitDirection = 'vertical',
|
|
): string | null => {
|
|
// Same pattern as appendHostToWorkspace — ref guard + authoritative
|
|
// inside-updater match to cover concurrent closeWorkspace.
|
|
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
|
|
|
|
const newSessionId = crypto.randomUUID();
|
|
const localHostId = `local-${newSessionId}`;
|
|
const newSession: TerminalSession = {
|
|
id: newSessionId,
|
|
hostId: localHostId,
|
|
hostLabel: options?.shellName || 'Local Terminal',
|
|
hostname: 'localhost',
|
|
username: 'local',
|
|
status: 'connecting',
|
|
protocol: 'local',
|
|
shellType: options?.shellType,
|
|
localShell: options?.shell,
|
|
localShellArgs: options?.shellArgs,
|
|
localShellName: options?.shellName,
|
|
localShellIcon: options?.shellIcon,
|
|
workspaceId,
|
|
};
|
|
|
|
setWorkspaces(prev => {
|
|
const target = prev.find(w => w.id === workspaceId);
|
|
if (!target) return prev;
|
|
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
|
|
setActiveTabId(workspaceId);
|
|
return prev.map(ws => {
|
|
if (ws.id !== workspaceId) return ws;
|
|
return {
|
|
...ws,
|
|
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
|
|
focusedSessionId: newSessionId,
|
|
};
|
|
});
|
|
});
|
|
return newSessionId;
|
|
}, [setActiveTabId]);
|
|
|
|
const updateSplitSizes = useCallback((workspaceId: string, splitId: string, sizes: number[]) => {
|
|
setWorkspaces(prev => prev.map(ws => {
|
|
if (ws.id !== workspaceId) return ws;
|
|
return { ...ws, root: updateWorkspaceSplitSizes(ws.root, splitId, sizes) };
|
|
}));
|
|
}, []);
|
|
|
|
// Split a session to create a workspace with the same host connection
|
|
// direction: 'horizontal' = split top/bottom, 'vertical' = split left/right
|
|
const splitSession = useCallback((
|
|
sessionId: string,
|
|
direction: SplitDirection,
|
|
options?: {
|
|
localShellType?: TerminalSession['shellType'];
|
|
},
|
|
) => {
|
|
setSessions(prevSessions => {
|
|
const session = prevSessions.find(s => s.id === sessionId);
|
|
if (!session) return prevSessions;
|
|
const nextShellType = session.protocol === 'local'
|
|
? options?.localShellType
|
|
: session.shellType;
|
|
|
|
// If session is already in a workspace, split within that workspace
|
|
if (session.workspaceId) {
|
|
// Create a new session with the same host
|
|
const newSession: TerminalSession = {
|
|
id: crypto.randomUUID(),
|
|
hostId: session.hostId,
|
|
hostLabel: session.hostLabel,
|
|
hostname: session.hostname,
|
|
username: session.username,
|
|
status: 'connecting',
|
|
workspaceId: session.workspaceId,
|
|
protocol: session.protocol,
|
|
port: session.port,
|
|
moshEnabled: session.moshEnabled,
|
|
shellType: nextShellType,
|
|
charset: session.charset,
|
|
localShell: session.localShell,
|
|
localShellArgs: session.localShellArgs,
|
|
localShellName: session.localShellName,
|
|
localShellIcon: session.localShellIcon,
|
|
};
|
|
|
|
// Add pane to existing workspace
|
|
const hint: SplitHint = {
|
|
direction,
|
|
position: direction === 'horizontal' ? 'bottom' : 'right',
|
|
targetSessionId: sessionId,
|
|
};
|
|
|
|
setWorkspaces(prevWorkspaces => {
|
|
return prevWorkspaces.map(ws => {
|
|
if (ws.id !== session.workspaceId) return ws;
|
|
return { ...ws, root: insertPaneIntoWorkspace(ws.root, newSession.id, hint) };
|
|
});
|
|
});
|
|
|
|
return [...prevSessions, newSession];
|
|
}
|
|
|
|
// Session is standalone - create a new workspace
|
|
const newSession: TerminalSession = {
|
|
id: crypto.randomUUID(),
|
|
hostId: session.hostId,
|
|
hostLabel: session.hostLabel,
|
|
hostname: session.hostname,
|
|
username: session.username,
|
|
status: 'connecting',
|
|
protocol: session.protocol,
|
|
port: session.port,
|
|
moshEnabled: session.moshEnabled,
|
|
shellType: nextShellType,
|
|
charset: session.charset,
|
|
localShell: session.localShell,
|
|
localShellArgs: session.localShellArgs,
|
|
localShellName: session.localShellName,
|
|
localShellIcon: session.localShellIcon,
|
|
};
|
|
|
|
const hint: SplitHint = {
|
|
direction,
|
|
position: direction === 'horizontal' ? 'bottom' : 'right',
|
|
};
|
|
|
|
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
|
|
setWorkspaces(prev => [...prev, newWorkspace]);
|
|
setActiveTabId(newWorkspace.id);
|
|
|
|
return prevSessions.map(s => {
|
|
if (s.id === sessionId) {
|
|
return { ...s, workspaceId: newWorkspace.id };
|
|
}
|
|
return s;
|
|
}).concat({ ...newSession, workspaceId: newWorkspace.id });
|
|
});
|
|
}, [setActiveTabId]);
|
|
|
|
// Toggle workspace view mode between split and focus
|
|
const toggleWorkspaceViewMode = useCallback((workspaceId: string) => {
|
|
setWorkspaces(prev => prev.map(ws => {
|
|
if (ws.id !== workspaceId) return ws;
|
|
const currentMode = ws.viewMode || 'split';
|
|
const newMode: WorkspaceViewMode = currentMode === 'split' ? 'focus' : 'split';
|
|
// If switching to focus mode and no focused session, pick the first one
|
|
let focusedSessionId = ws.focusedSessionId;
|
|
if (newMode === 'focus' && !focusedSessionId) {
|
|
const sessionIds = collectSessionIds(ws.root);
|
|
focusedSessionId = sessionIds[0];
|
|
}
|
|
return { ...ws, viewMode: newMode, focusedSessionId };
|
|
}));
|
|
}, []);
|
|
|
|
// Set the focused session in a workspace (for focus mode)
|
|
const setWorkspaceFocusedSession = useCallback((workspaceId: string, sessionId: string) => {
|
|
setWorkspaces(prev => prev.map(ws => {
|
|
if (ws.id !== workspaceId) return ws;
|
|
return { ...ws, focusedSessionId: sessionId };
|
|
}));
|
|
}, []);
|
|
|
|
// Move focus between panes in a workspace
|
|
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
|
|
const workspace = workspaces.find(w => w.id === workspaceId);
|
|
if (!workspace) {
|
|
return false;
|
|
}
|
|
|
|
// Get current focused session, or first session if none focused
|
|
const sessionIds = collectSessionIds(workspace.root);
|
|
|
|
const currentFocused = workspace.focusedSessionId || sessionIds[0];
|
|
if (!currentFocused) {
|
|
return false;
|
|
}
|
|
|
|
// Find the next session in the given direction
|
|
const nextSessionId = getNextFocusSessionId(workspace.root, currentFocused, direction);
|
|
|
|
if (!nextSessionId) {
|
|
return false;
|
|
}
|
|
|
|
// Update focused session
|
|
setWorkspaces(prev => prev.map(ws => {
|
|
if (ws.id !== workspaceId) return ws;
|
|
return { ...ws, focusedSessionId: nextSessionId };
|
|
}));
|
|
|
|
return true;
|
|
}, [workspaces]);
|
|
|
|
// Run a snippet on multiple target hosts - creates a focus mode workspace
|
|
const runSnippet = useCallback((snippet: Snippet, targetHosts: Host[]) => {
|
|
if (targetHosts.length === 0) return;
|
|
|
|
// Create sessions for each target host
|
|
const newSessions: TerminalSession[] = targetHosts.map(host => ({
|
|
id: crypto.randomUUID(),
|
|
hostId: host.id,
|
|
hostLabel: host.label,
|
|
hostname: host.hostname,
|
|
username: host.username,
|
|
status: 'connecting' as const,
|
|
charset: host.charset,
|
|
// workspaceId will be set after workspace is created
|
|
}));
|
|
|
|
const sessionIds = newSessions.map(s => s.id);
|
|
|
|
// Create a focus mode workspace
|
|
const workspace = createWorkspaceFromSessionIds(sessionIds, {
|
|
title: snippet.label,
|
|
viewMode: 'focus',
|
|
snippetId: snippet.id,
|
|
});
|
|
|
|
// Update sessions with workspaceId
|
|
const sessionsWithWorkspace = newSessions.map(s => ({
|
|
...s,
|
|
workspaceId: workspace.id,
|
|
// Store the command to run after connection
|
|
startupCommand: snippet.command,
|
|
noAutoRun: snippet.noAutoRun,
|
|
}));
|
|
|
|
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
|
|
setWorkspaces(prev => [...prev, workspace]);
|
|
setActiveTabId(workspace.id);
|
|
}, [setActiveTabId]);
|
|
|
|
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId), [sessions]);
|
|
|
|
// Open a log view tab
|
|
const openLogView = useCallback((log: ConnectionLog) => {
|
|
const tabId = `log-${log.id}`;
|
|
// Check if already open
|
|
setLogViews(prev => {
|
|
if (prev.some(lv => lv.connectionLogId === log.id)) {
|
|
// Already open, just switch to it
|
|
setActiveTabId(tabId);
|
|
return prev;
|
|
}
|
|
// Open new log view
|
|
const newLogView: LogView = {
|
|
id: tabId,
|
|
connectionLogId: log.id,
|
|
log,
|
|
};
|
|
setActiveTabId(tabId);
|
|
return [...prev, newLogView];
|
|
});
|
|
}, [setActiveTabId]);
|
|
|
|
// Close a log view tab
|
|
const closeLogView = useCallback((logViewId: string) => {
|
|
setLogViews(prev => {
|
|
const updated = prev.filter(lv => lv.id !== logViewId);
|
|
// If this was the active tab, switch to vault
|
|
const currentActiveTabId = activeTabStore.getActiveTabId();
|
|
if (currentActiveTabId === logViewId) {
|
|
const fallback = updated.length > 0 ? updated[updated.length - 1].id : 'vault';
|
|
setActiveTabId(fallback);
|
|
}
|
|
return updated;
|
|
});
|
|
}, [setActiveTabId]);
|
|
|
|
// Copy a session - creates a new session with the same host connection
|
|
const copySession = useCallback((sessionId: string, options?: {
|
|
localShellType?: TerminalSession['shellType'];
|
|
}) => {
|
|
// Pre-allocate the new id outside the updater so StrictMode's
|
|
// double-invocation of the functional updater doesn't mint two ids.
|
|
const newSessionId = crypto.randomUUID();
|
|
|
|
setSessions(prevSessions => {
|
|
const session = prevSessions.find(s => s.id === sessionId);
|
|
// Source may have been closed between the user's action and this
|
|
// update running; in that case skip entirely — do NOT switch the
|
|
// active tab or insert into tabOrder, which would leave dangling ids.
|
|
if (!session) return prevSessions;
|
|
const nextShellType = session.protocol === 'local'
|
|
? options?.localShellType
|
|
: session.shellType;
|
|
|
|
const newSession: TerminalSession = {
|
|
id: newSessionId,
|
|
hostId: session.hostId,
|
|
hostLabel: session.hostLabel,
|
|
hostname: session.hostname,
|
|
username: session.username,
|
|
status: 'connecting',
|
|
protocol: session.protocol,
|
|
port: session.port,
|
|
moshEnabled: session.moshEnabled,
|
|
shellType: nextShellType,
|
|
charset: session.charset,
|
|
serialConfig: session.serialConfig,
|
|
localShell: session.localShell,
|
|
localShellArgs: session.localShellArgs,
|
|
localShellName: session.localShellName,
|
|
localShellIcon: session.localShellIcon,
|
|
};
|
|
|
|
// Schedule the activeTab + tabOrder updates only when creation
|
|
// actually happens. These nested setStates are idempotent, so
|
|
// StrictMode's double-invocation is harmless.
|
|
setActiveTabId(newSessionId);
|
|
setTabOrder(prevTabOrder => {
|
|
// Fast path: source is already tracked in tabOrder — splice directly.
|
|
const directIdx = prevTabOrder.indexOf(sessionId);
|
|
if (directIdx !== -1) {
|
|
const next = [...prevTabOrder];
|
|
next.splice(directIdx + 1, 0, newSessionId);
|
|
return next;
|
|
}
|
|
// Fallback: source is only in the derived tab collections. Rebuild the
|
|
// effective order (same pattern as reorderTabs) to locate its position.
|
|
const allTabIds = [
|
|
...orphanSessions.map(s => s.id),
|
|
...workspaces.map(w => w.id),
|
|
...logViews.map(lv => lv.id),
|
|
];
|
|
const allTabIdSet = new Set(allTabIds);
|
|
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
|
const orderedIdSet = new Set(orderedIds);
|
|
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
|
const currentOrder = [...orderedIds, ...newIds];
|
|
const sourceIdx = currentOrder.indexOf(sessionId);
|
|
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
|
|
const next = [...currentOrder];
|
|
next.splice(sourceIdx + 1, 0, newSessionId);
|
|
return next;
|
|
});
|
|
|
|
return [...prevSessions, newSession];
|
|
});
|
|
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
|
|
|
|
// Toggle broadcast mode for a workspace
|
|
const toggleBroadcast = useCallback((workspaceId: string) => {
|
|
setBroadcastWorkspaceIds(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(workspaceId)) {
|
|
next.delete(workspaceId);
|
|
} else {
|
|
next.add(workspaceId);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Check if a workspace has broadcast enabled
|
|
const isBroadcastEnabled = useCallback((workspaceId: string) => {
|
|
return broadcastWorkspaceIds.has(workspaceId);
|
|
}, [broadcastWorkspaceIds]);
|
|
|
|
// Get ordered tabs: combines orphan sessions, workspaces, and log views in the custom order
|
|
const orderedTabs = useMemo(() => {
|
|
const allTabIds = [
|
|
...orphanSessions.map(s => s.id),
|
|
...workspaces.map(w => w.id),
|
|
...logViews.map(lv => lv.id),
|
|
];
|
|
const allTabIdSet = new Set(allTabIds);
|
|
// Filter tabOrder to only include existing tabs, then add any new tabs at the end
|
|
const orderedIds = tabOrder.filter(id => allTabIdSet.has(id));
|
|
const orderedIdSet = new Set(orderedIds);
|
|
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
|
return [...orderedIds, ...newIds];
|
|
}, [orphanSessions, workspaces, logViews, tabOrder]);
|
|
|
|
const reorderTabs = useCallback((draggedId: string, targetId: string, position: 'before' | 'after' = 'before') => {
|
|
if (draggedId === targetId) return;
|
|
|
|
setTabOrder(prevTabOrder => {
|
|
// Get all current tab IDs (orphan sessions + workspaces + log views)
|
|
const allTabIds = [
|
|
...orphanSessions.map(s => s.id),
|
|
...workspaces.map(w => w.id),
|
|
...logViews.map(lv => lv.id),
|
|
];
|
|
const allTabIdSet = new Set(allTabIds);
|
|
|
|
// Build current effective order: existing order + new tabs at end
|
|
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
|
const orderedIdSet = new Set(orderedIds);
|
|
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
|
const currentOrder = [...orderedIds, ...newIds];
|
|
|
|
const draggedIndex = currentOrder.indexOf(draggedId);
|
|
const targetIndex = currentOrder.indexOf(targetId);
|
|
|
|
if (draggedIndex === -1 || targetIndex === -1) return prevTabOrder;
|
|
|
|
// Remove dragged item first
|
|
currentOrder.splice(draggedIndex, 1);
|
|
|
|
// Calculate new target index (adjusted after removal)
|
|
let newTargetIndex = targetIndex;
|
|
if (draggedIndex < targetIndex) {
|
|
newTargetIndex -= 1;
|
|
}
|
|
|
|
// Insert at the correct position
|
|
if (position === 'after') {
|
|
newTargetIndex += 1;
|
|
}
|
|
|
|
currentOrder.splice(newTargetIndex, 0, draggedId);
|
|
|
|
return currentOrder;
|
|
});
|
|
}, [orphanSessions, workspaces, logViews]);
|
|
|
|
return {
|
|
sessions,
|
|
workspaces,
|
|
// activeTabId removed - components should subscribe via useActiveTabId() from activeTabStore
|
|
setActiveTabId,
|
|
draggingSessionId,
|
|
setDraggingSessionId,
|
|
sessionRenameTarget,
|
|
sessionRenameValue,
|
|
setSessionRenameValue,
|
|
startSessionRename,
|
|
submitSessionRename,
|
|
resetSessionRename,
|
|
workspaceRenameTarget,
|
|
workspaceRenameValue,
|
|
setWorkspaceRenameValue,
|
|
startWorkspaceRename,
|
|
submitWorkspaceRename,
|
|
resetWorkspaceRename,
|
|
createLocalTerminal,
|
|
createSerialSession,
|
|
connectToHost,
|
|
closeSession,
|
|
closeWorkspace,
|
|
updateSessionStatus,
|
|
createWorkspaceWithHosts,
|
|
createWorkspaceFromTargets,
|
|
createWorkspaceFromSessions,
|
|
addSessionToWorkspace,
|
|
appendHostToWorkspace,
|
|
appendLocalTerminalToWorkspace,
|
|
updateSplitSizes,
|
|
splitSession,
|
|
toggleWorkspaceViewMode,
|
|
setWorkspaceFocusedSession,
|
|
moveFocusInWorkspace,
|
|
runSnippet,
|
|
orphanSessions,
|
|
// Broadcast mode
|
|
toggleBroadcast,
|
|
isBroadcastEnabled,
|
|
orderedTabs,
|
|
reorderTabs,
|
|
// Log views
|
|
logViews,
|
|
openLogView,
|
|
closeLogView,
|
|
// Copy session
|
|
copySession,
|
|
};
|
|
};
|