Files
NetMesh/domain/sftpFollowTerminalCwd.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

271 lines
9.5 KiB
TypeScript

/** Pure state and decision helpers for keeping an SFTP pane aligned with a terminal. */
export type SftpFollowTerminalCwdBlock = {
connectionId: string;
terminalCwd: string;
};
export type SftpFollowTerminalCwdContext = {
followEnabled: boolean;
isVisible: boolean;
terminalCwd?: string | null;
currentPath?: string | null;
connectionId?: string | null;
hasActiveWork: boolean;
isConnected: boolean;
/** Skip auto-follow while this terminal cwd cannot be reached on SFTP. */
blockedFollow?: SftpFollowTerminalCwdBlock | null;
/** Skip auto-follow after this terminal cwd was already handled for the connection. */
handledFollow?: SftpFollowTerminalCwdBlock | null;
};
export type SftpFollowTerminalCwdSyncResultContext = {
syncGeneration: number;
currentGeneration: number;
followEnabled: boolean;
canFollow: boolean;
expectedSessionId?: string | null;
liveSessionId?: string | null;
expectedConnectionId?: string | null;
liveConnectionId?: string | null;
paneConnectionId?: string | null;
expectedTerminalCwd?: string | null;
liveTerminalCwd?: string | null;
requireLiveTerminalCwd?: boolean;
};
/**
* Whether a change of the live `activeTerminalCwd` input must invalidate the
* handled/blocked follow bookkeeping.
*
* Hidden surfaces receive `activeTerminalCwd={null}`
* (terminalLayerSidePanelSlots) and get the live value back when the tab
* becomes visible again. That visibility-induced `null` is synthetic and must
* not invalidate: invalidating on it would drop `handledFollowRef` and make
* the regular follow sync navigate the user's browsed pane back to the
* terminal cwd (clearing the filename filter) on every terminal-tab switch.
*
* While the surface is visible, though, a `null` is a real transition: the
* linked terminal session moved to (or closed on) a session whose cwd cache is
* empty. Passing it through would leave the generation valid, and the
* first-open eligibility checks do not bind to the terminal session identity,
* so an in-flight probe of the previous session could navigate the visible
* pane. Any live value change while visible invalidates — including to `null`.
*/
export const shouldInvalidateFollowBookkeepingOnCwdChange = ({
nextCwd,
lastCwd,
isVisible,
}: {
nextCwd: string | null;
lastCwd: string | null;
isVisible: boolean;
}): boolean => {
if (nextCwd === lastCwd) return false;
// Only the hidden synthetic `null` pass-through is ignored; a caller that
// still forwards live values while hidden keeps the concrete-change rule.
if (!isVisible) return nextCwd !== null;
return true;
};
/**
* Whether the one-shot "first open" terminal cwd sync must re-arm.
*
* Hiding the surface while the owner side panel stays open (switching terminal
* tabs, or focusing another side-panel tool in the same tab) must NOT re-arm
* the sync: re-running it would navigate the pane away from the user's browsed
* directory back to the terminal cwd and clear the filename filter on every
* switch. A changed terminal cwd is still followed by the regular follow sync
* through its handled/blocked bookkeeping. The sync re-arms only when the
* owning panel actually closed (fresh open resyncs, #2335) or the linked
* connection replaced itself (Start over).
*/
export const shouldResetInitialFollowTerminalCwdSync = ({
isVisible,
ownerPanelOpen,
connectionId,
trackedConnectionId,
}: {
isVisible: boolean;
/** Side panel still open for this terminal tab (another tool/tab may have focus). */
ownerPanelOpen: boolean;
connectionId: string | null;
trackedConnectionId: string | null;
}): boolean => {
if (connectionId !== trackedConnectionId) return true;
return !isVisible && !ownerPanelOpen;
};
/**
* Whether a hide transition with the owning panel still open (terminal-tab
* switch, or focusing another side-panel tool in the same tab) must latch the
* pending first-open fresh-CWD probe as interrupted.
*
* When the surface is hidden while a probe is still in flight, the probe's
* eligibility is only re-checked live when it resolves. If the tab becomes
* visible again before then, the live checks pass again (generation unchanged,
* visible, no active work) and the stale probe would navigate the pane back to
* the terminal cwd, clearing the user's browsed directory and filename filter.
* The hide transition must latch the attempt as interrupted while keeping its
* one-shot slot consumed (no re-arm on return — see
* {@link shouldReleaseInitialFollowSyncAttempt}).
*/
export const shouldLatchInitialFollowInterruption = ({
isVisible,
ownerPanelOpen,
}: {
isVisible: boolean;
ownerPanelOpen: boolean;
}): boolean => !isVisible && ownerPanelOpen;
/**
* Whether an unfinished one-shot "first open" sync attempt may release its
* consumed slot so a retry can re-arm.
*
* If the fresh-CWD probe resolves while the surface is hidden but the owning
* panel is still open (terminal-tab switch), the attempt must stay consumed:
* releasing it would re-run the first-open sync when the tab becomes visible
* again and navigate the pane away from the directory the user browsed to
* while the original probe was pending (clearing its filename filter). The
* attempt is released once the surface is visible again or the owning panel
* closed (fresh open resyncs, #2335).
*/
export const shouldReleaseInitialFollowSyncAttempt = ({
isVisible,
ownerPanelOpen,
}: {
isVisible: boolean;
ownerPanelOpen: boolean;
}): boolean => isVisible || !ownerPanelOpen;
export const resolveHostFollowTerminalCwd = (
hostFollowTerminalCwd: boolean | undefined,
globalFollowTerminalCwd: boolean,
): boolean => hostFollowTerminalCwd ?? globalFollowTerminalCwd;
export const resolveSftpFollowTerminalCwdTargetHost = <T>(
visibleHost: T | null | undefined,
fallbackHost: T | null | undefined,
): T | null => visibleHost ?? fallbackHost ?? null;
export const mergeLatestFollowTerminalCwdHostSetting = <
T extends { id?: string; sftpFollowTerminalCwd?: boolean },
>(
displayHost: T | null | undefined,
latestHost: T | null | undefined,
pendingFollowOverride?: boolean,
): T | null => {
if (!displayHost) return latestHost ?? null;
if (!latestHost || latestHost.id !== displayHost.id) return displayHost;
return {
...latestHost,
...displayHost,
sftpFollowTerminalCwd:
latestHost.sftpFollowTerminalCwd !== undefined
? latestHost.sftpFollowTerminalCwd
: pendingFollowOverride,
};
};
/** Clear a follow block once the user reaches the blocked cwd through any navigation. */
export const shouldClearBlockedFollowOnReach = (
blockedFollow: SftpFollowTerminalCwdBlock | null | undefined,
connectionId: string | null | undefined,
currentPath: string | null | undefined,
loading: boolean,
): boolean => {
if (loading || !blockedFollow || !connectionId || !currentPath) return false;
return (
blockedFollow.connectionId === connectionId
&& blockedFollow.terminalCwd === currentPath
);
};
/**
* Whether a first-open or in-flight follow still belongs to the terminal that
* started it. A missing origin id is not a skipped guard: the live focused
* terminal must stay absent too, otherwise the probe belongs to a later pane.
*/
export const isFollowOriginStillCurrent = ({
expectedOriginId,
liveOriginId,
}: {
expectedOriginId: string | null | undefined;
liveOriginId?: string | null;
}): boolean => {
if (liveOriginId === undefined) return true;
if (expectedOriginId == null) return liveOriginId == null;
return liveOriginId === expectedOriginId;
};
/** Whether an async follow result still belongs to the current terminal/connection state. */
export const shouldApplyFollowTerminalCwdSyncResult = ({
syncGeneration,
currentGeneration,
followEnabled,
canFollow,
expectedSessionId,
liveSessionId,
expectedConnectionId,
liveConnectionId,
paneConnectionId,
expectedTerminalCwd,
liveTerminalCwd,
requireLiveTerminalCwd = false,
}: SftpFollowTerminalCwdSyncResultContext): boolean => {
if (syncGeneration !== currentGeneration || !followEnabled || !canFollow) {
return false;
}
if (expectedSessionId !== undefined) {
if (!isFollowOriginStillCurrent({
expectedOriginId: expectedSessionId,
liveOriginId: liveSessionId,
})) return false;
}
if (expectedConnectionId !== undefined) {
if (!expectedConnectionId) return false;
if (liveConnectionId !== undefined && liveConnectionId !== expectedConnectionId) return false;
if (paneConnectionId !== undefined && paneConnectionId !== expectedConnectionId) return false;
}
if (expectedTerminalCwd !== undefined) {
if (requireLiveTerminalCwd && !liveTerminalCwd) return false;
if (liveTerminalCwd && liveTerminalCwd !== expectedTerminalCwd) return false;
}
return true;
};
/** Whether SFTP should auto-navigate to match the linked terminal cwd. */
export const shouldFollowTerminalCwdNavigate = ({
followEnabled,
isVisible,
terminalCwd,
currentPath,
connectionId,
hasActiveWork,
isConnected,
blockedFollow,
handledFollow,
}: SftpFollowTerminalCwdContext): boolean => {
if (!followEnabled || !isVisible || !isConnected) return false;
if (hasActiveWork) return false;
if (!terminalCwd || terminalCwd.trim().length === 0) return false;
if (
handledFollow
&& connectionId
&& handledFollow.connectionId === connectionId
&& handledFollow.terminalCwd === terminalCwd
) {
return false;
}
if (
blockedFollow
&& connectionId
&& blockedFollow.connectionId === connectionId
&& blockedFollow.terminalCwd === terminalCwd
) {
return false;
}
if (!currentPath || currentPath === terminalCwd) return false;
return true;
};